chahinebrini 7ec4be810b feat(rebreak-native): AppAlert composable, avatar compression, FamilyControls gate
- 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>
2026-05-12 21:47:18 +02:00

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>
);
}