import { useEffect, useRef, useState } from 'react'; import { Modal, View, Text, TouchableOpacity, Animated, Easing } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; // ─── Message sanitization ───────────────────────────────────────────────────── const HTML_TAG_RE = /<[^>]+>/g; const STATUS_413_RE = /413|Request Entity Too Large|client_max_body_size/i; const STATUS_4XX_RE = /API\s+4\d{2}|HTTP\s+4\d{2}/i; const STATUS_5XX_RE = /API\s+5\d{2}|HTTP\s+5\d{2}|nginx|Internal Server Error/i; const LOOKS_HTML_RE = / 0 ? stripped.slice(0, 240) : null }; } if (STATUS_4XX_RE.test(raw) || STATUS_5XX_RE.test(raw)) { return { display: '', detail: stripped.length > 0 ? stripped.slice(0, 240) : null }; } if (LOOKS_HTML_RE.test(raw) || LOOKS_JSON_RE.test(raw) || stripped.length > 240) { return { display: '', detail: stripped.slice(0, 240) }; } return { display: stripped, detail: null }; } function friendlyMessage(raw: string, t: (k: string) => string): string { if (STATUS_413_RE.test(raw)) return t('alert.error_file_too_large'); return t('alert.error_generic'); } // ─── Types ──────────────────────────────────────────────────────────────────── export type AppAlertMode = 'error' | 'confirm' | 'success'; type BaseProps = { visible: boolean; title: string; message?: string; }; type ErrorProps = BaseProps & { mode: 'error'; onClose: () => void; }; type SuccessProps = BaseProps & { mode: 'success'; onClose: () => void; }; type ConfirmProps = BaseProps & { mode: 'confirm'; confirmLabel?: string; cancelLabel?: string; destructive?: boolean; onConfirm: () => void; onCancel: () => void; }; export type AppAlertProps = ErrorProps | SuccessProps | ConfirmProps; // ─── Component ─────────────────────────────────────────────────────────────── export function AppAlert(props: AppAlertProps) { const { t } = useTranslation(); const { visible, title, message, mode } = props; const cardScale = useRef(new Animated.Value(0.85)).current; const cardOpacity = useRef(new Animated.Value(0)).current; const iconScale = useRef(new Animated.Value(0)).current; const iconRotate = useRef(new Animated.Value(0)).current; const [detailExpanded, setDetailExpanded] = useState(false); useEffect(() => { if (!visible) { setDetailExpanded(false); return; } cardScale.setValue(0.85); cardOpacity.setValue(0); iconScale.setValue(0); iconRotate.setValue(0); const animateIcon = mode === 'success'; Animated.parallel([ Animated.spring(cardScale, { toValue: 1, useNativeDriver: true, friction: 7, tension: 80, }), Animated.timing(cardOpacity, { toValue: 1, duration: 200, useNativeDriver: true, easing: Easing.out(Easing.cubic), }), animateIcon ? Animated.sequence([ Animated.delay(130), Animated.parallel([ Animated.spring(iconScale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 180, }), Animated.timing(iconRotate, { toValue: 1, duration: 360, useNativeDriver: true, easing: Easing.out(Easing.back(1.7)), }), ]), ]) : Animated.timing(iconScale, { toValue: 1, duration: 1, useNativeDriver: true, }), ]).start(); }, [visible, mode, cardScale, cardOpacity, iconScale, iconRotate]); const rotateInterpolate = iconRotate.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'], }); // Sanitize message once const rawMessage = message ?? ''; const { display: sanitizedDisplay, detail } = sanitizeMessage(rawMessage); const displayMessage = sanitizedDisplay.length > 0 ? sanitizedDisplay : friendlyMessage(rawMessage, t); const modeConfig = { error: { iconName: 'alert-circle' as const, iconBg: '#dc2626', okColor: '#dc2626', okBg: '#fef2f2', okBorder: '#fecaca', }, success: { iconName: 'checkmark' as const, iconBg: '#16a34a', okColor: '#007AFF', okBg: '#eff6ff', okBorder: '#bfdbfe', }, confirm: { iconName: 'help-circle' as const, iconBg: '#007AFF', okColor: '#007AFF', okBg: '#eff6ff', okBorder: '#bfdbfe', }, }[mode]; const onDismiss = mode === 'confirm' ? (props as ConfirmProps).onCancel : (props as ErrorProps | SuccessProps).onClose; const confirmLabel = mode === 'confirm' ? ((props as ConfirmProps).confirmLabel ?? t('common.confirm')) : t('common.ok'); const cancelLabel = mode === 'confirm' ? ((props as ConfirmProps).cancelLabel ?? t('common.cancel')) : null; const destructive = mode === 'confirm' ? ((props as ConfirmProps).destructive ?? false) : false; const showMessage = mode === 'error' ? (rawMessage.length === 0 ? false : true) : !!message; const resolvedDisplayMessage = mode === 'error' ? displayMessage : (message ?? ''); return ( {}} style={{ width: '88%', maxWidth: 340 }}> {/* Icon circle — animated for success, static otherwise */} {/* Title */} {title} {/* Message */} {showMessage && ( {resolvedDisplayMessage} )} {/* Expandable detail section for sanitized raw errors */} {detail && ( setDetailExpanded((x) => !x)} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 4, }} > {t('alert.details_label')} {detailExpanded && ( {detail} )} )} {/* Buttons */} {mode === 'confirm' ? ( {cancelLabel} {confirmLabel} ) : ( {t('common.ok')} )} ); }