- components/AppAlert.tsx — one parametrized alert composable (error / success / confirm), replacing scattered Alert.alert(). error mode sanitizes raw response bodies (strips HTML, detects 413/5xx/nginx → friendly generic text, raw text only in a collapsible "Details" section). Light backdrop, TouchableOpacity. - profile/AvatarCropSheet — compress the cropped avatar via expo-image-manipulator (max 512×512, JPEG q0.7 → ~50–150 KB) before upload, so the nginx 1 MB cap on staging.rebreak.org/api/ no longer 413s; compress errors shown via AppAlert. (adds expo-image-manipulator ~14.0.7 — needs a fresh dev build) - lib/protection.ts — FAMILY_CONTROLS_AVAILABLE = expoConfig.extra.familyControlsEnabled - app/(app)/blocker.tsx — App-Lock toggle only rendered when FAMILY_CONTROLS_AVAILABLE; otherwise a quiet "App-Lock — coming soon" row + "bald" badge. The URL-filter banner / ProtectionLockedCard stay positive (the filter carries the protection). - de/en strings for alert.* and blocker.app_lock_coming_soon_* Follow-ups: nginx client_max_body_size → ≥5 MB on staging (backyard, separate); ConfirmAlert/SuccessAlert call-site sweep onto AppAlert (separate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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 = /<!DOCTYPE|<html|<body|<head/i;
|
|
const LOOKS_JSON_RE = /^\s*[{[]/;
|
|
|
|
function sanitizeMessage(raw: string): { display: string; detail: string | null } {
|
|
const stripped = raw.replace(HTML_TAG_RE, ' ').replace(/\s+/g, ' ').trim();
|
|
|
|
if (STATUS_413_RE.test(raw)) {
|
|
return { display: '', detail: stripped.length > 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 (
|
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onDismiss}>
|
|
<TouchableOpacity
|
|
activeOpacity={1}
|
|
onPress={onDismiss}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 24,
|
|
}}
|
|
>
|
|
<TouchableOpacity activeOpacity={1} onPress={() => {}} style={{ width: '88%', maxWidth: 340 }}>
|
|
<Animated.View
|
|
style={{
|
|
backgroundColor: '#fff',
|
|
borderRadius: 22,
|
|
padding: 22,
|
|
width: '100%',
|
|
transform: [{ scale: cardScale }],
|
|
opacity: cardOpacity,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowOpacity: 0.18,
|
|
shadowRadius: 24,
|
|
elevation: 16,
|
|
}}
|
|
>
|
|
{/* Icon circle — animated for success, static otherwise */}
|
|
<Animated.View
|
|
style={{
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
backgroundColor: modeConfig.iconBg,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 14,
|
|
alignSelf: 'center',
|
|
transform:
|
|
mode === 'success'
|
|
? [{ scale: iconScale }, { rotate: rotateInterpolate }]
|
|
: [{ scale: iconScale }],
|
|
shadowColor: modeConfig.iconBg,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.35,
|
|
shadowRadius: 8,
|
|
elevation: 6,
|
|
}}
|
|
>
|
|
<Ionicons name={modeConfig.iconName} size={32} color="#fff" />
|
|
</Animated.View>
|
|
|
|
{/* Title */}
|
|
<Text
|
|
style={{
|
|
fontSize: 17,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#0a0a0a',
|
|
textAlign: 'center',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
|
|
{/* Message */}
|
|
{showMessage && (
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#525252',
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
marginBottom: detail ? 6 : 18,
|
|
}}
|
|
>
|
|
{resolvedDisplayMessage}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Expandable detail section for sanitized raw errors */}
|
|
{detail && (
|
|
<View style={{ marginBottom: 18 }}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={() => setDetailExpanded((x) => !x)}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 4,
|
|
paddingVertical: 4,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#a3a3a3',
|
|
}}
|
|
>
|
|
{t('alert.details_label')}
|
|
</Text>
|
|
<Ionicons
|
|
name={detailExpanded ? 'chevron-up' : 'chevron-down'}
|
|
size={12}
|
|
color="#a3a3a3"
|
|
/>
|
|
</TouchableOpacity>
|
|
{detailExpanded && (
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#a3a3a3',
|
|
textAlign: 'center',
|
|
lineHeight: 16,
|
|
marginTop: 4,
|
|
paddingHorizontal: 4,
|
|
}}
|
|
>
|
|
{detail}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Buttons */}
|
|
{mode === 'confirm' ? (
|
|
<View style={{ flexDirection: 'row', gap: 10 }}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={(props as ConfirmProps).onCancel}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
backgroundColor: '#f5f5f5',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: '#0a0a0a',
|
|
}}
|
|
>
|
|
{cancelLabel}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={(props as ConfirmProps).onConfirm}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
backgroundColor: destructive ? '#fef2f2' : '#eff6ff',
|
|
borderWidth: 1,
|
|
borderColor: destructive ? '#fecaca' : '#bfdbfe',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: destructive ? '#dc2626' : '#007AFF',
|
|
}}
|
|
>
|
|
{confirmLabel}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={onDismiss}
|
|
style={{
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
backgroundColor: modeConfig.okBg,
|
|
borderWidth: 1,
|
|
borderColor: modeConfig.okBorder,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: modeConfig.okColor,
|
|
}}
|
|
>
|
|
{t('common.ok')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
</Modal>
|
|
);
|
|
}
|