chahinebrini f19d00017a feat: pre-check global blocklist on add + collapse Mails on load
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:

1. backend/custom-domains/index.post.ts: before any slot-limit check or
   DB insert, look the domain up in blocklist_domain (active rows). If
   present, return 200 { alreadyGlobal: true, domain }. No row gets
   written, no slot consumed. The existing frontend hook + AddSheet
   already handle the alreadyGlobal flag — they surface the
   "bereits global blockiert" alert and don't refresh as if the entry
   landed in the user's list.

2. blocker.tsx default mailOpen state flipped from true to false so the
   Eigene Mails section starts collapsed on page load. Domains stays
   the primary affordance; mail-patterns are an opt-in expansion.
2026-05-16 02:42:42 +02:00

595 lines
20 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Animated, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader';
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
import { DomainGrid } from '../../components/blocker/DomainGrid';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme';
export default function BlockerScreen() {
const router = useRouter();
const { t } = useTranslation();
const colors = useColors();
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
// hinterm Tab-Bar verschwinden.
const tabBarHeight = useBottomTabBarHeight();
const {
state,
loading,
cooldownRemainingFormatted,
refresh,
activateUrlFilter,
activateFamilyControls,
requestDeactivation,
cancelDeactivation,
} = useProtectionState();
const plan = state?.plan ?? 'free';
const {
domains,
tier,
countsByType,
limits,
addDomain,
submitDomain,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist } = useBlocklistSync();
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
const onDomainChange = useCallback(async () => {
await refreshDomains();
if (urlFilterActiveRef.current) {
const sync = await syncBlocklist();
console.log('[blocker] resync after domain change:', sync);
await refresh();
}
}, [refreshDomains, syncBlocklist, refresh]);
useDomainSubmissionRealtime(onDomainChange, true);
const [mailOpen, setMailOpen] = useState(false);
// AddSheet state: tracks which section opened it
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [addSheetKind, setAddSheetKind] = useState<'web' | 'mail'>('web');
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
const lockedIn = appDeletionLockActive;
const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist.
const syncedOnceRef = useRef(false);
useEffect(() => {
if (!urlFilterActive) return;
if (syncedOnceRef.current) return;
syncedOnceRef.current = true;
syncBlocklist().then((res) => {
console.log('[blocker] auto-sync on mount:', res);
if (res.ok) refresh();
});
}, [urlFilterActive, syncBlocklist, refresh]);
// ─── Activate-Handler pro Layer ──────────────────────────────────────
async function handleActivateUrlFilter() {
try {
const result = await activateUrlFilter();
console.log('[blocker] activateUrlFilter:', result);
if (!result.enabled) {
Alert.alert(
t('blocker.activate_url_failed_title'),
result.error ?? t('blocker.activate_url_failed_msg'),
[
{ text: t('common.ok') },
{ text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() },
],
);
} else {
const sync = await syncBlocklist();
console.log('[blocker] post-activate sync:', sync);
if (sync.ok) {
await refresh();
} else {
Alert.alert(
t('blocker.sync_list_failed_title'),
sync.error ?? t('blocker.sync_list_failed_msg'),
);
}
}
return result;
} catch (e: any) {
console.error('[blocker] activateUrlFilter threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
return { enabled: false };
}
}
async function handleActivateFamilyControls() {
try {
const result = await activateFamilyControls();
console.log('[blocker] activateFamilyControls:', result);
// `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
if (!result.enabled && result.error !== 'accessibility_pending') {
Alert.alert(
t('blocker.activate_app_lock_failed_title'),
result.error ?? t('blocker.activate_app_lock_failed_msg'),
);
}
return result;
} catch (e: any) {
console.error('[blocker] activateFamilyControls threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
}
return { enabled: false };
}
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
function openDetails() {
setDetailsOpen(true);
}
function fromDetailsToExplainer() {
setDetailsOpen(false);
setTimeout(() => setExplainerOpen(true), 250);
}
function deflectToLyra() {
setDetailsOpen(false);
setTimeout(() => router.push('/lyra' as any), 250);
}
function deflectToBreathe() {
setExplainerOpen(false);
setTimeout(() => router.push('/urge' as any), 250);
}
async function handleStartCooldown(reason: string) {
await requestDeactivation(reason);
}
async function handleCancelCooldown() {
try {
await cancelDeactivation();
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed'));
}
}
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
bypassAlertShownRef.current = false;
return;
}
if (bypassAlertShownRef.current) return;
bypassAlertShownRef.current = true;
Alert.alert(
t('blocker.protection_off_title'),
t('blocker.protection_off_message'),
[
{ text: t('common.ok'), style: 'cancel' },
{ text: t('blocker.reactivate_btn'), onPress: () => { void handleActivateUrlFilter(); } },
],
);
}, [state?.phase, t]);
function openAddSheet(kind: 'web' | 'mail') {
setAddSheetKind(kind);
setAddSheetOpen(true);
}
// ─── Render ──────────────────────────────────────────────────────────
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
{loading && !state ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#737373" />
</View>
) : state ? (
<>
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: tabBarHeight + 80,
gap: 14,
}}
showsVerticalScrollIndicator={false}
>
{/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
{lockedIn ? (
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
) : (
<View style={{ gap: 10 }}>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
{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>
)}
{/* CooldownBanner */}
{state.cooldown.active && (
<CooldownBanner
remainingFormatted={cooldownRemainingFormatted}
onCancel={handleCancelCooldown}
/>
)}
{/* Free: Erwartungs-Transparenz-Hinweis */}
{plan === 'free' && (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
}}
>
<Ionicons name="shield-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 17 }}>
{t('plan_limit.blocker_basic_protection')}
</Text>
</View>
)}
{/* Über-Limit Banner */}
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
<View
style={{
backgroundColor: 'rgba(217,119,6,0.08)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(217,119,6,0.2)',
gap: 4,
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
{t('plan_limit.blocker_domain_over_limit', {
used: tier.usedSlots,
plan: plan.charAt(0).toUpperCase() + plan.slice(1),
max: tier.domainLimit,
})}
</Text>
</View>
)}
{/* Section 1: Eigene Domains */}
<DomainSection
title={t('blocker.section_domains')}
count={countsByType.web}
max={limits.web}
onAdd={() => openAddSheet('web')}
atLimit={countsByType.web >= limits.web}
>
<DomainGrid
domains={domains}
tier={tier}
kind="web"
onSubmit={submitDomain}
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
/>
</DomainSection>
{/* Section 2: Eigene Mails */}
<DomainSection
title={t('blocker.section_mails')}
count={countsByType.mail}
max={limits.mail}
collapsible
open={mailOpen}
onToggle={() => setMailOpen((v) => !v)}
onAdd={() => openAddSheet('mail')}
atLimit={countsByType.mail >= limits.mail}
>
<DomainGrid
domains={domains}
tier={tier}
kind="mail"
onSubmit={submitDomain}
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
/>
</DomainSection>
</ScrollView>
{/* Sheets */}
<AddDomainSheet
visible={addSheetOpen}
tier={tier}
initialType={addSheetKind}
onClose={() => {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (pattern, kind) => {
const result = await addDomain(pattern, kind);
if (result.ok) {
const sync = await syncBlocklist();
if (sync.ok) refresh();
}
return result;
}}
/>
<ProtectionDetailsSheet
visible={detailsOpen}
state={state}
onClose={() => setDetailsOpen(false)}
onRequestDeactivation={fromDetailsToExplainer}
onTalkToLyra={deflectToLyra}
/>
<DeactivationExplainerSheet
visible={explainerOpen}
onClose={() => setExplainerOpen(false)}
onBreathe={deflectToBreathe}
onStartCooldown={handleStartCooldown}
/>
</>
) : null}
</View>
);
}
// ─── DomainSection ────────────────────────────────────────────────────────────
function DomainSection({
title,
count,
max,
collapsible = false,
open = true,
onToggle,
onAdd,
atLimit,
children,
}: {
title: string;
count: number;
max: number;
collapsible?: boolean;
open?: boolean;
onToggle?: () => void;
onAdd: () => void;
atLimit: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
// Animated progress bar
const fillAnim = useRef(new Animated.Value(0)).current;
const ratio = max > 0 ? Math.min(count / max, 1) : 0;
useEffect(() => {
Animated.timing(fillAnim, {
toValue: ratio,
duration: 380,
useNativeDriver: false,
}).start();
}, [ratio]);
const pct = ratio * 100;
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated;
const badgeFg = atLimit ? '#dc2626' : colors.textMuted;
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
}}
>
{/* Section Header */}
<TouchableOpacity
onPress={collapsible ? onToggle : undefined}
activeOpacity={collapsible ? 0.7 : 1}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
gap: 8,
}}
>
<Text style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{title}
</Text>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: badgeBg,
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: badgeFg }}>
{t('blocker.count_label', { count, max })}
</Text>
</View>
{collapsible && (
<Ionicons
name={open ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.textMuted}
/>
)}
</TouchableOpacity>
{(!collapsible || open) && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 12 }}>
{/* Progressbar */}
<View
style={{
height: 5,
borderRadius: 3,
backgroundColor: colors.surfaceElevated,
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
borderRadius: 3,
backgroundColor: barColor,
width: fillAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
}),
}}
/>
</View>
{/* Add-Button */}
<TouchableOpacity
onPress={atLimit ? undefined : onAdd}
disabled={atLimit}
activeOpacity={0.75}
style={{ alignSelf: 'flex-start' }}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
backgroundColor: atLimit ? colors.surfaceElevated : '#007AFF',
opacity: atLimit ? 0.5 : 1,
}}
>
<Ionicons name="add" size={16} color={atLimit ? colors.textMuted : '#fff'} />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: atLimit ? colors.textMuted : '#fff',
}}
>
{t('blocker.add_domain')}
</Text>
</View>
</TouchableOpacity>
{/* Grid */}
{children}
</View>
)}
</View>
);
}