import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Image as RNImage, View } from 'react-native'; import { applyStyles, parseSizeOrPercent } from '../../utils/styles'; import { usePaywallContext } from '../../context/PaywallContext'; import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText'; import { getImageAccessibilityProps } from './imageAccessibility'; // Prefer expo-image when available; fall back to react-native Image let ImageComponent: typeof RNImage; let supportsContentPosition = false; let supportsContentFit = false; try { const expoImage = require('expo-image'); ImageComponent = expoImage.Image; supportsContentPosition = true; supportsContentFit = true; } catch { ImageComponent = RNImage; } interface Props { component: any; scaleFactor: number; parentDirection?: string; } const aspectRatioCache = new Map(); export const NamiImage: React.FC = ({ component, scaleFactor, parentDirection }) => { const ctx = usePaywallContext(); const [sourceAspectRatio, setSourceAspectRatio] = useState(() => { const raw = component?.url ?? component?.imageUrl; if (!raw || typeof raw !== 'string') { return null; } return aspectRatioCache.get(raw) ?? null; }); const smartTextSku = component?.smartTextSku ?? component?.sku; const url = useMemo(() => { const raw = component.url ?? component.imageUrl ?? ''; const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku); const resolved = interpolateSmartText(raw, replacements); return resolved == null ? '' : String(resolved); }, [component.url, component.imageUrl, smartTextSku, ctx.state, ctx.flow]); if (!url) return null; const rawW = component.width ?? component.fixedWidth; const rawH = component.height ?? component.fixedHeight; const h = parseSizeOrPercent(rawH ?? '100%', scaleFactor); const w = rawW === 'fitContent' && h ? h : parseSizeOrPercent(rawW ?? '100%', scaleFactor); const hasPercentWidth = typeof rawW === 'string' && rawW.trim().endsWith('%'); const numericHeight = typeof h === 'number' ? h : null; const prefersEdgeAlignment = component.alignment === 'left' || component.alignment === 'leading' || component.alignment === 'right' || component.alignment === 'trailing'; const shouldUseNativeEdgeFit = component.imageCropping === 'fit' && hasPercentWidth && numericHeight != null && prefersEdgeAlignment; // On Vega/TV targets, contentPosition alone can still visually center a contained image // inside a full-width slot. For left/right aligned fit images, render them at measured // fitted width and let the parent container anchor them to the edge. const containerStyle: any = applyStyles(component, scaleFactor, false, parentDirection); const horizontalAlignment = component.alignment === 'left' || component.alignment === 'leading' ? 'flex-start' : component.alignment === 'right' || component.alignment === 'trailing' ? 'flex-end' : 'center'; const contentPosition = component.alignment === 'left' || component.alignment === 'leading' ? { left: 0, top: '50%' as const } : component.alignment === 'right' || component.alignment === 'trailing' ? { right: 0, top: '50%' as const } : 'center'; containerStyle.alignItems = horizontalAlignment; const imageStyle: any = { height: h ?? '100%', flexShrink: typeof rawW === 'number' ? 0 : 1, marginRight: component.rightMargin ?? component.spacing ?? 0, maxWidth: '100%', alignSelf: horizontalAlignment, width: shouldUseNativeEdgeFit && sourceAspectRatio != null ? numericHeight! * sourceAspectRatio : (w ?? '100%'), }; const contentFit = component.imageCropping === 'fit' ? 'contain' : 'cover'; const edgeFitReady = !shouldUseNativeEdgeFit || sourceAspectRatio != null; useEffect(() => { if (!shouldUseNativeEdgeFit || !url) { return; } const cachedRatio = aspectRatioCache.get(url); if (cachedRatio != null) { setSourceAspectRatio((prev) => (prev === cachedRatio ? prev : cachedRatio)); return; } let cancelled = false; RNImage.getSize( url, (width, height) => { if (cancelled || !height) return; const next = width / height; if (!Number.isFinite(next) || next <= 0) return; aspectRatioCache.set(url, next); setSourceAspectRatio((prev) => (prev === next ? prev : next)); }, () => { // Fall back to onLoad when size prefetch is unavailable on the target runtime. }, ); return () => { cancelled = true; }; }, [shouldUseNativeEdgeFit, url]); const applyLoadedSource = useCallback((source: any) => { if (!shouldUseNativeEdgeFit) { return; } const width = source?.width; const height = source?.height; if (typeof width !== 'number' || typeof height !== 'number' || height <= 0) return; const next = width / height; if (!Number.isFinite(next) || next <= 0) return; aspectRatioCache.set(url, next); setSourceAspectRatio((prev) => (prev === next ? prev : next)); }, [shouldUseNativeEdgeFit, url]); const onNativeImageLoad = useCallback((event: any) => { applyLoadedSource(event?.nativeEvent?.source); }, [applyLoadedSource]); const onExpoImageLoad = useCallback((event: any) => { applyLoadedSource(event?.source); }, [applyLoadedSource]); // NAM-1450: non-blank alt exposes the image to screen readers; otherwise the // RN default accessible={false} keeps it decorative. const accessibilityProps = getImageAccessibilityProps(component); return ( {shouldUseNativeEdgeFit ? ( ) : ( )} ); }; const styles = { hidden: { opacity: 0, }, };