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.
595 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|