311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { ScrollView, View, Alert, ActivityIndicator } from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
|
import { useTranslation } from 'react-i18next';
|
|
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 } from '../../lib/protection';
|
|
|
|
export default function BlockerScreen() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
// 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,
|
|
addDomain,
|
|
submitDomain,
|
|
refresh: refreshDomains,
|
|
} = useCustomDomains(plan);
|
|
const { sync: syncBlocklist } = useBlocklistSync();
|
|
|
|
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
|
|
// Bei domain_rejected wird die Row backend-seitig hard-deleted → refetch
|
|
// entfernt sie aus der Liste. Zusätzlich blocklist.bin neu syncen damit
|
|
// die lokale Hash-Liste nicht aus dem Tritt gerät.
|
|
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);
|
|
|
|
// Sheet-States
|
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [explainerOpen, setExplainerOpen] = useState(false);
|
|
|
|
// Layer-Status (auf iOS): urlFilter + familyControls.
|
|
// AppDeletionLock=true bedeutet "locked in" → keine Switches mehr, nur Cooldown-Pfad.
|
|
const urlFilterActive = state?.layers.urlFilter === true;
|
|
const familyControlsActive = state?.layers.familyControls === true;
|
|
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
|
const lockedIn = appDeletionLockActive;
|
|
|
|
// Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle
|
|
const urlFilterActiveRef = useRef(urlFilterActive);
|
|
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
|
|
|
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und
|
|
// blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht
|
|
// bei jedem Re-Render neu syncen.
|
|
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(); // Stats-Card neu rendern mit aktuellem Count
|
|
});
|
|
}, [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 {
|
|
// Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen!
|
|
// Sonst zeigt iOS "Läuft" aber blockt nichts.
|
|
const sync = await syncBlocklist();
|
|
console.log('[blocker] post-activate sync:', sync);
|
|
if (sync.ok) {
|
|
// Stats-Card neu rendern mit dem frisch geschriebenen Count
|
|
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);
|
|
if (!result.enabled) {
|
|
Alert.alert(
|
|
t('blocker.activate_app_lock_failed_title'),
|
|
result.error ?? t('blocker.activate_app_lock_failed_msg'),
|
|
);
|
|
}
|
|
} 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.activate_app_lock_failed_title'),
|
|
t('blocker.layers_app_lock_warning'),
|
|
[{
|
|
text: t('common.ok'),
|
|
onPress: () => {
|
|
void handleActivateFamilyControls();
|
|
},
|
|
}],
|
|
{ cancelable: false },
|
|
);
|
|
}, [state?.phase, t]);
|
|
|
|
// ─── 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, // platz für FAB + Tab-Bar
|
|
gap: 14,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
|
|
{lockedIn ? (
|
|
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
|
) : (
|
|
// FC nicht aktiv → User kann pro Layer einzeln togglen
|
|
<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}
|
|
/>
|
|
<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>
|
|
)}
|
|
|
|
{/* CooldownBanner — nur wenn Cooldown läuft */}
|
|
{state.cooldown.active && (
|
|
<CooldownBanner
|
|
remainingFormatted={cooldownRemainingFormatted}
|
|
onCancel={handleCancelCooldown}
|
|
/>
|
|
)}
|
|
|
|
{/* Domain Grid mit inline + Button neben SlotPill */}
|
|
<View style={{ marginTop: 8 }}>
|
|
<DomainGrid
|
|
domains={domains}
|
|
tier={tier}
|
|
onAdd={() => setAddSheetOpen(true)}
|
|
onSubmit={submitDomain}
|
|
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Sheets */}
|
|
<AddDomainSheet
|
|
visible={addSheetOpen}
|
|
tier={tier}
|
|
onClose={() => {
|
|
setAddSheetOpen(false);
|
|
refreshDomains();
|
|
}}
|
|
onAdd={async (d) => {
|
|
const result = await addDomain(d);
|
|
if (result.ok) {
|
|
// Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen
|
|
const sync = await syncBlocklist();
|
|
if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen
|
|
}
|
|
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>
|
|
);
|
|
}
|