import * as React from "react"; import { ActivityIndicator, Animated, FlatList, Image, Linking, Modal, Pressable, SafeAreaView, StyleSheet, Text, useColorScheme, View, } from "react-native"; import type { ImageSourcePropType, ImageStyle, ListRenderItem, StyleProp, TextStyle, ViewStyle, ViewToken, } from "react-native"; import { WebView } from "react-native-webview"; import type { WebViewNavigation } from "react-native-webview"; import type { MCCampaign, MCMeta } from "../domain"; import { MCOfferwallSDK } from "../../offerwall/MCOfferwallSDK"; const DEFAULT_CURRENCY_ICON_URL = "https://mychips.b-cdn.net/static/icons/soldino.png"; export type MCNativeAdViewHandle = { load: () => void; }; // customize individual parts of the built-in card // without replacing the complete renderer. export type MCDefaultCardStyles = { card?: StyleProp; image?: StyleProp; title?: StyleProp; reward?: StyleProp; currencyIcon?: StyleProp; promo?: StyleProp; progress?: StyleProp; }; export type MCNativeAdViewProps = { horizontal?: boolean; maxCampaigns?: number; useCache?: boolean; nestedScrollEnabled?: boolean; currencyIconUrl?: string | null; currencyIconSource?: ImageSourcePropType | null; // applies only to the built-in default card. // It is ignored when renderCampaign is provided. defaultCardStyles?: MCDefaultCardStyles; style?: StyleProp; contentContainerStyle?: StyleProp; renderCampaign?: (campaign: MCCampaign, index: number) => React.ReactElement; // Allows a custom renderer to provide a matching skeleton item. // Equivalent to Android using the renderer layout and iOS using // the renderer item size during loading. renderSkeleton?: (index: number) => React.ReactElement; // Number of skeleton cards displayed while loading. skeletonCount?: number; loadingView?: React.ReactElement; onLoadingStarted?: () => void; onCampaignsLoaded?: (campaigns: MCCampaign[], meta: MCMeta) => void; onError?: (error: Error) => void; onCampaignClick?: ( campaign: MCCampaign, index: number, openDefault: () => void, ) => void; }; export const MCNativeAdView = React.forwardRef< MCNativeAdViewHandle, MCNativeAdViewProps >(function MCNativeAdView( { horizontal = true, maxCampaigns = 0, useCache = false, nestedScrollEnabled = true, currencyIconUrl = DEFAULT_CURRENCY_ICON_URL, currencyIconSource, defaultCardStyles, style, contentContainerStyle, renderCampaign, renderSkeleton, // resolved from the list direction below. skeletonCount, loadingView, onLoadingStarted, onCampaignsLoaded, onError, onCampaignClick, }, ref, ): React.ReactElement { // Follow the host application's effective color // scheme. Explicit colors avoid invisible Android text when an app theme // resolves ?attr/textColorPrimary to an unexpected value. const colorScheme = useColorScheme(); const themeColors = React.useMemo( () => ({ text: colorScheme === "dark" ? "#FFFFFF" : "#17171C", background: colorScheme === "dark" ? "#121212" : "#FFFFFF", }), [colorScheme], ); const [loadedCampaigns, setLoadedCampaigns] = React.useState( [], ); const [loading, setLoading] = React.useState(true); const [reloadToken, setReloadToken] = React.useState(0); const [detailUrl, setDetailUrl] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); const [webViewCanGoBack, setWebViewCanGoBack] = React.useState(false); const webViewRef = React.useRef>(null); const impressedIds = React.useRef(new Set()); const maxCampaignsRef = React.useRef(maxCampaigns); maxCampaignsRef.current = maxCampaigns; const campaigns = React.useMemo( () => maxCampaigns > 0 ? loadedCampaigns.slice(0, maxCampaigns) : loadedCampaigns, [loadedCampaigns, maxCampaigns], ); const resolvedCurrencyIconSource = currencyIconSource === undefined ? currencyIconUrl ? { uri: currencyIconUrl } : null : currencyIconSource; const callbacks = React.useRef({ onLoadingStarted, onCampaignsLoaded, onError, }); callbacks.current = { onLoadingStarted, onCampaignsLoaded, onError }; React.useImperativeHandle( ref, () => ({ load: () => setReloadToken((value) => value + 1) }), [], ); React.useEffect(() => { let active = true; setLoading(true); callbacks.current.onLoadingStarted?.(); MCOfferwallSDK.getCampaigns(useCache) .then(({ campaigns: loaded, meta }) => { if (!active) return; const max = maxCampaignsRef.current; const visible = max > 0 ? loaded.slice(0, max) : loaded; // A fresh load starts a fresh per-list // impression set, as Android does with a new adapter and iOS explicitly. impressedIds.current.clear(); setLoadedCampaigns(loaded); setLoading(false); callbacks.current.onCampaignsLoaded?.(visible, meta); }) .catch((cause: unknown) => { if (!active) return; const error = cause instanceof Error ? cause : new Error(String(cause)); setLoading(false); callbacks.current.onError?.(error); }); return () => { active = false; }; }, [reloadToken, useCache]); const viewabilityConfig = React.useRef({ itemVisiblePercentThreshold: 50, }).current; const onViewableItemsChanged = React.useRef( ({ viewableItems }: { viewableItems: ViewToken[] }) => { viewableItems.forEach(({ item, isViewable }) => { const campaign = item as MCCampaign; if (isViewable && !impressedIds.current.has(campaign.id)) { impressedIds.current.add(campaign.id); MCOfferwallSDK.trackImpression(campaign); } }); }, ).current; const openDefault = React.useCallback((campaign: MCCampaign) => { MCOfferwallSDK.onClick(campaign, (url) => { setWebViewCanGoBack(false); setDetailUrl(url); }).catch((cause: unknown) => { const error = cause instanceof Error ? cause : new Error(String(cause)); callbacks.current.onError?.(error); }); }, []); const handleClick = React.useCallback( (campaign: MCCampaign, index: number) => { const continueWithDefault = () => openDefault(campaign); if (onCampaignClick) { onCampaignClick(campaign, index, continueWithDefault); } else { continueWithDefault(); } }, [onCampaignClick, openDefault], ); // Same redirect condition as Android/iOS. // Keep the detail page in-app, but send tracking redirects to the browser. const handleShouldStartLoadWithRequest = React.useCallback( (request: { url: string }): boolean => { const { url } = request; if ( url.startsWith("https://api.mychips.io") && url.includes("redirect") ) { Linking.openURL(url).catch((cause: unknown) => { const error = cause instanceof Error ? cause : new Error(String(cause)); callbacks.current.onError?.(error); }); return false; } return true; }, [], ); const closeDetail = React.useCallback(() => { setDetailUrl(null); setDetailLoading(false); setWebViewCanGoBack(false); }, []); // Android back / toolbar back first navigates the // detail WebView history, then closes the modal when no history remains. const handleDetailBack = React.useCallback(() => { if (webViewCanGoBack) { webViewRef.current?.goBack(); } else { closeDetail(); } }, [closeDetail, webViewCanGoBack]); const handleNavigationChange = React.useCallback( (state: WebViewNavigation) => setWebViewCanGoBack(state.canGoBack), [], ); const renderItem: ListRenderItem = ({ item, index }) => ( handleClick(item, index)}> {renderCampaign ? ( renderCampaign(item, index) ) : ( )} ); if (loading) { return ( loadingView ?? ( ) ); } return ( item.id} renderItem={renderItem} contentContainerStyle={contentContainerStyle} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} /> {/* NATIVE-PARITY [IOS-SAFE-AREA]: Keep the toolbar below the iOS status bar/notch. */} {webViewCanGoBack ? "Back" : "Close"} {MCOfferwallSDK.getToolbarTitle() ?? ""} {detailUrl ? ( setDetailLoading(true)} onLoadEnd={() => setDetailLoading(false)} onNavigationStateChange={handleNavigationChange} onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} /> {detailLoading ? ( ) : null} ) : null} ); }); const formatReward = (value: number): string => { try { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0, }).format(value); } catch { return String(Math.round(value)); } }; const formatPromo = (ratio: number): string => `x${String(ratio)} Rewards`; const DefaultCard = ({ campaign, currencyIconSource, textColor, defaultCardStyles, }: { campaign: MCCampaign; currencyIconSource: ImageSourcePropType | null; textColor: string; defaultCardStyles?: MCDefaultCardStyles; }): React.ReactElement => { const imageUrl = campaign.creatives.thumbnail ?? campaign.creatives.cover; const progressStatus = campaign.progress?.status; const showProgress = progressStatus != null && progressStatus !== "completed" && progressStatus !== "closed"; return ( // Same 140-wide transparent default card structure. {imageUrl ? ( ) : ( )} {campaign.promoRatio > 1 ? ( {formatPromo(campaign.promoRatio)} ) : null} {campaign.name} {currencyIconSource ? ( ) : null} {formatReward(campaign.totalConvertedValue)} ); }; // The default skeleton is still used for the default renderer. // A custom renderer can now provide an item with the same shape. const DefaultSkeleton = ({ horizontal, style, defaultCardStyles, renderSkeleton, skeletonCount, }: { horizontal: boolean; style?: StyleProp; defaultCardStyles?: MCDefaultCardStyles; renderSkeleton?: (index: number) => React.ReactElement; skeletonCount: number; }): React.ReactElement => { const opacity = React.useRef(new Animated.Value(0.35)).current; React.useEffect(() => { const animation = Animated.loop( Animated.sequence([ Animated.timing(opacity, { toValue: 0.85, duration: 650, useNativeDriver: true, }), Animated.timing(opacity, { toValue: 0.35, duration: 650, useNativeDriver: true, }), ]), ); animation.start(); return () => { animation.stop(); }; }, [opacity]); // Protect Array.from() from invalid values such as Infinity or NaN. // NATIVE CAMPAIGNS: UPDATED - invalid values also use the direction default. const defaultSkeletonCount = horizontal ? 5 : 3; const safeSkeletonCount = Number.isFinite(skeletonCount) ? Math.max(1, Math.floor(skeletonCount)) : defaultSkeletonCount; // copy only View-compatible image geometry. // This makes the built-in skeleton switch between rounded, square and // circular whenever defaultCardStyles.image changes. const imageStyle = StyleSheet.flatten(defaultCardStyles?.image); const skeletonImageShape: ViewStyle = {}; if (imageStyle?.width !== undefined) { skeletonImageShape.width = imageStyle.width; } if (imageStyle?.height !== undefined) { skeletonImageShape.height = imageStyle.height; } if (imageStyle?.borderRadius !== undefined) { skeletonImageShape.borderRadius = imageStyle.borderRadius; } if (imageStyle?.borderTopLeftRadius !== undefined) { skeletonImageShape.borderTopLeftRadius = imageStyle.borderTopLeftRadius; } if (imageStyle?.borderTopRightRadius !== undefined) { skeletonImageShape.borderTopRightRadius = imageStyle.borderTopRightRadius; } if (imageStyle?.borderBottomLeftRadius !== undefined) { skeletonImageShape.borderBottomLeftRadius = imageStyle.borderBottomLeftRadius; } if (imageStyle?.borderBottomRightRadius !== undefined) { skeletonImageShape.borderBottomRightRadius = imageStyle.borderBottomRightRadius; } return ( {Array.from({ length: safeSkeletonCount }, (_, index) => ( {renderSkeleton ? ( renderSkeleton(index) ) : ( )} ))} ); }; const styles = StyleSheet.create({ card: { width: 140, minHeight: 240, margin: 8, backgroundColor: "transparent", }, image: { width: 140, height: 140, borderRadius: 12 }, placeholder: { backgroundColor: "#E6E6E6" }, title: { minHeight: 38, marginTop: 6, fontSize: 14, fontWeight: "600", }, rewardRow: { flexDirection: "row", alignItems: "center", marginTop: 4 }, currencyIcon: { width: 18, height: 18, marginRight: 5 }, reward: { fontSize: 14, fontWeight: "600" }, promo: { position: "absolute", top: 8, right: 8, overflow: "hidden", borderRadius: 10, paddingHorizontal: 7, paddingVertical: 3, backgroundColor: "#D83B01", color: "#FFFFFF", fontSize: 10, fontWeight: "700", }, progress: { alignSelf: "flex-start", overflow: "hidden", minHeight: 20, marginTop: 5, borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: "#E8F2FF", color: "#146EF5", fontSize: 11, fontWeight: "600", }, progressHidden: { opacity: 0 }, skeletonList: { minHeight: 240, overflow: "hidden" }, skeletonHorizontal: { flexDirection: "row" }, skeletonVertical: { flexDirection: "column" }, skeletonCard: { width: 140, minHeight: 230, margin: 8 }, skeletonImage: { width: 140, height: 140, borderRadius: 12, backgroundColor: "#C8C8C8", }, skeletonLineWide: { width: 125, height: 14, marginTop: 10, borderRadius: 7, backgroundColor: "#C8C8C8", }, skeletonLineShort: { width: 78, height: 14, marginTop: 8, borderRadius: 7, backgroundColor: "#C8C8C8", }, modal: { flex: 1 }, toolbar: { minHeight: 52, flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#8E8E93", }, toolbarButton: { minWidth: 72, minHeight: 52, justifyContent: "center", paddingHorizontal: 16, }, toolbarButtonSpacer: { width: 72 }, toolbarButtonText: { color: "#146EF5", fontSize: 16, fontWeight: "600" }, toolbarTitle: { flex: 1, textAlign: "center", fontSize: 16, fontWeight: "600", }, webViewContainer: { flex: 1 }, detailLoader: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", }, });