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>
This commit is contained in:
chahinebrini 2026-05-12 21:47:18 +02:00
parent e48a3187a6
commit 7ec4be810b
8 changed files with 537 additions and 34 deletions

View File

@ -16,7 +16,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection } from '../../lib/protection';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme';
export default function BlockerScreen() {
@ -242,18 +242,77 @@ export default function BlockerScreen() {
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
{FAMILY_CONTROLS_AVAILABLE ? (
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
) : (
<View
style={{
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 16,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.layers_app_lock_title')}
</Text>
<View
style={{
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.app_lock_coming_soon_badge')}
</Text>
</View>
</View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{t('blocker.app_lock_coming_soon_desc')}
</Text>
</View>
</View>
)}
</View>
)}

View File

@ -0,0 +1,397 @@
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>
);
}

View File

@ -8,22 +8,18 @@ import {
ActivityIndicator,
StyleSheet,
Dimensions,
Platform,
} from 'react-native';
import {
GestureDetector,
Gesture,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { AppAlert } from '../AppAlert';
const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320);
const SPRING = { damping: 18, stiffness: 200 };
@ -39,6 +35,7 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [processing, setProcessing] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
@ -87,18 +84,18 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
async function handleConfirm() {
if (!imageUri || processing) return;
setProcessing(true);
// TODO(expo-image-manipulator): install `expo-image-manipulator` then replace this pass-through:
// const result = await manipulateAsync(
// imageUri,
// [
// { crop: { originX, originY, width: cropSizeInPx, height: cropSizeInPx } },
// { resize: { width: 512, height: 512 } },
// ],
// { format: SaveFormat.JPEG, compress: 0.8 },
// );
// onConfirm(result.uri);
onConfirm(imageUri);
setProcessing(false);
try {
const result = await manipulateAsync(
imageUri,
[{ resize: { width: 512, height: 512 } }],
{ format: SaveFormat.JPEG, compress: 0.7 },
);
onConfirm(result.uri);
} catch (e: any) {
setUploadError(e?.message ?? t('alert.error_generic'));
} finally {
setProcessing(false);
}
}
return (
@ -170,6 +167,14 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
</View>
</View>
</GestureHandlerRootView>
<AppAlert
mode="error"
visible={uploadError !== null}
title={t('alert.compress_error_title')}
message={uploadError ?? ''}
onClose={() => setUploadError(null)}
/>
</Modal>
);
}

View File

@ -9,6 +9,7 @@
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
*/
import { Platform } from "react-native";
import Constants from "expo-constants";
import AsyncStorage from "@react-native-async-storage/async-storage";
import RebreakProtection from "../modules/rebreak-protection";
import type {
@ -22,6 +23,17 @@ import type {
} from "../modules/rebreak-protection";
import { apiFetch } from "./api";
// ─── Feature Flags ─────────────────────────────────────────────────────────
/**
* True only in dev/dev-client builds where Apple's FamilyControls Development
* entitlement is active. False in preview/production (Distribution entitlement
* pending Apple approval) showing the App-Lock toggle in those builds would
* throw NSCocoaErrorDomain:4099.
*/
export const FAMILY_CONTROLS_AVAILABLE =
Constants.expoConfig?.extra?.familyControlsEnabled === true;
// ─── Public Types ──────────────────────────────────────────────────────────
export type ProtectionPhase =

View File

@ -308,7 +308,9 @@
"more_info_title": "Schutz deaktivieren",
"cooldown_elapsed_title": "Schutz ist aus",
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
"cooldown_elapsed_open_settings": "Einstellungen öffnen"
"cooldown_elapsed_open_settings": "Einstellungen öffnen",
"app_lock_coming_soon_badge": "Bald",
"app_lock_coming_soon_desc": "App-Sperre wird bald verfügbar — Schutz ist bereits aktiv."
},
"mail": {
"title": "Mail-Schutz",
@ -866,5 +868,11 @@
"post_to_community": "Posten",
"posted": "Im Community-Feed gepostet",
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
},
"alert": {
"error_generic": "Etwas ist schiefgelaufen — versuch es nochmal.",
"error_file_too_large": "Das Bild ist zu groß.",
"details_label": "Details",
"compress_error_title": "Bild konnte nicht verarbeitet werden"
}
}

View File

@ -308,7 +308,9 @@
"more_info_title": "Disable protection",
"cooldown_elapsed_title": "Protection is off",
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
"cooldown_elapsed_open_settings": "Open Settings"
"cooldown_elapsed_open_settings": "Open Settings",
"app_lock_coming_soon_badge": "Soon",
"app_lock_coming_soon_desc": "App lock coming soon — filter protection is already active."
},
"mail": {
"title": "Mail Shield",
@ -866,5 +868,11 @@
"post_to_community": "Post",
"posted": "Posted to the community feed",
"post_error": "Posting failed, please try again"
},
"alert": {
"error_generic": "Something went wrong — please try again.",
"error_file_too_large": "The image is too large.",
"details_label": "Details",
"compress_error_title": "Could not process image"
}
}

View File

@ -35,6 +35,7 @@
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8",

13
pnpm-lock.yaml generated
View File

@ -189,6 +189,9 @@ importers:
expo-haptics:
specifier: ^15.0.8
version: 15.0.8(expo@54.0.34)
expo-image-manipulator:
specifier: ~14.0.7
version: 14.0.8(expo@54.0.34)
expo-image-picker:
specifier: ~17.0.11
version: 17.0.11(expo@54.0.34)
@ -5546,6 +5549,11 @@ packages:
peerDependencies:
expo: '*'
expo-image-manipulator@14.0.8:
resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
peerDependencies:
expo: '*'
expo-image-picker@17.0.11:
resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==}
peerDependencies:
@ -15531,6 +15539,11 @@ snapshots:
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
expo-image-manipulator@14.0.8(expo@54.0.34):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
expo-image-loader: 6.0.0(expo@54.0.34)
expo-image-picker@17.0.11(expo@54.0.34):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)