From 7ec4be810bbf31b1bf0646c0bced298c5c0da9cd Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Tue, 12 May 2026 21:47:18 +0200 Subject: [PATCH] feat(rebreak-native): AppAlert composable, avatar compression, FamilyControls gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/rebreak-native/app/(app)/blocker.tsx | 85 +++- apps/rebreak-native/components/AppAlert.tsx | 397 ++++++++++++++++++ .../components/profile/AvatarCropSheet.tsx | 43 +- apps/rebreak-native/lib/protection.ts | 12 + apps/rebreak-native/locales/de.json | 10 +- apps/rebreak-native/locales/en.json | 10 +- apps/rebreak-native/package.json | 1 + pnpm-lock.yaml | 13 + 8 files changed, 537 insertions(+), 34 deletions(-) create mode 100644 apps/rebreak-native/components/AppAlert.tsx diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 73394bc..4a1419c 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -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} /> - + {FAMILY_CONTROLS_AVAILABLE ? ( + + ) : ( + + + + + + + + {t('blocker.layers_app_lock_title')} + + + + {t('blocker.app_lock_coming_soon_badge')} + + + + + {t('blocker.app_lock_coming_soon_desc')} + + + + )} )} diff --git a/apps/rebreak-native/components/AppAlert.tsx b/apps/rebreak-native/components/AppAlert.tsx new file mode 100644 index 0000000..903d4e7 --- /dev/null +++ b/apps/rebreak-native/components/AppAlert.tsx @@ -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 = / 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')} + + + )} + + + + + ); +} diff --git a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx index 2e83e9b..4ec524e 100644 --- a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx +++ b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx @@ -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(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) { + + setUploadError(null)} + /> ); } diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index 7cd7504..c917145 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -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 = diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 01e0a1a..dfdab02 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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" } } diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 17bf0e2..b7ca713 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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" } } diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index b7ab00e..2d67df9 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2600f69..79c1b0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)