rebreak-monorepo/apps/rebreak-native/hooks/useProtectionState.ts

164 lines
5.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import {
protection,
type ProtectionState,
type ProtectionPhase,
formatCooldownRemaining,
} from '../lib/protection';
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
const POLL_MS_NORMAL = 30_000;
type UseProtectionStateReturn = {
state: ProtectionState | null;
loading: boolean;
error: string | null;
/** Live Countdown-String "23:59:42" während Cooldown läuft. */
cooldownRemainingFormatted: string;
/** Refetch ohne loading-flicker. */
refresh: () => Promise<void>;
/** Aktiviert ALLE Layers (legacy, beide Dialoge nacheinander). */
activate: () => Promise<{ allLayersOn: boolean; missingLayers: string[] }>;
/** Aktiviert NUR den URL-Filter (NEFilter). */
activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
/** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */
activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
/** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */
requestDeactivation: (reason?: string) => Promise<void>;
/** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */
cancelDeactivation: () => Promise<void>;
};
/**
* Single-Source-of-Truth-Hook für Protection-State.
*
* - Initial-Fetch on mount
* - Polling: alle 30s normal, 5s während aktivem Cooldown (Live-Countdown)
* - Refresh on AppState 'active' (User kommt aus Background zurück)
* - Layer-Change-Listener vom Native-Modul (Bypass-Detection)
*/
export function useProtectionState(): UseProtectionStateReturn {
const [state, setState] = useState<ProtectionState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tickSeconds, setTickSeconds] = useState<number>(0);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchState = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const next = await protection.getCombinedState();
setState(next);
setTickSeconds(next.cooldown.remainingSeconds);
setError(null);
} catch (e: any) {
setError(e?.message ?? 'unknown');
} finally {
if (showLoading) setLoading(false);
}
}, []);
// Initial fetch
useEffect(() => {
fetchState(true);
}, [fetchState]);
// Adaptive poll-rate: 5s während Cooldown, 30s sonst
useEffect(() => {
const interval = state?.cooldown.active ? POLL_MS_ACTIVE_COOLDOWN : POLL_MS_NORMAL;
if (pollTimer.current) clearInterval(pollTimer.current);
pollTimer.current = setInterval(() => fetchState(false), interval);
return () => {
if (pollTimer.current) clearInterval(pollTimer.current);
};
}, [state?.cooldown.active, fetchState]);
// Live-Countdown-Tick (nur während Cooldown — 1s-Decrement client-side)
useEffect(() => {
if (!state?.cooldown.active) {
if (tickTimer.current) {
clearInterval(tickTimer.current);
tickTimer.current = null;
}
return;
}
tickTimer.current = setInterval(() => {
setTickSeconds((s) => Math.max(0, s - 1));
}, 1000);
return () => {
if (tickTimer.current) clearInterval(tickTimer.current);
};
}, [state?.cooldown.active]);
// AppState-Listener: Refresh wenn App aus Background zurückkommt.
// KEIN auto-disable hier — Backend's canDisableProtection-Flag ist auch
// in initial-state true, würde sonst den Filter killen ohne dass User
// jemals einen Cooldown gestartet hat. Auto-Disable nur über expliziten
// UI-Pfad nach Cooldown-Ablauf (kommt in Step 5b).
useEffect(() => {
const sub = AppState.addEventListener('change', (status: AppStateStatus) => {
if (status === 'active') {
fetchState(false);
}
});
return () => sub.remove();
}, [fetchState]);
// Native Layer-Change-Listener (User schaltet VPN extern aus etc.)
useEffect(() => {
const sub = protection.addLayerChangeListener(() => fetchState(false));
return () => sub?.remove();
}, [fetchState]);
// ─── Public Actions ────────────────────────────────────────────────
const activate = useCallback(async () => {
const result = await protection.activate();
await fetchState(false);
return result;
}, [fetchState]);
const activateUrlFilter = useCallback(async () => {
const result = await protection.activateUrlFilter();
await fetchState(false);
return result;
}, [fetchState]);
const activateFamilyControls = useCallback(async () => {
const result = await protection.activateFamilyControls();
await fetchState(false);
return result;
}, [fetchState]);
const requestDeactivation = useCallback(
async (reason?: string) => {
await protection.requestDeactivation(reason);
await fetchState(false);
},
[fetchState],
);
const cancelDeactivation = useCallback(async () => {
await protection.cancelDeactivation();
await fetchState(false);
}, [fetchState]);
return {
state,
loading,
error,
cooldownRemainingFormatted: formatCooldownRemaining(tickSeconds),
refresh: () => fetchState(false),
activate,
activateUrlFilter,
activateFamilyControls,
requestDeactivation,
cancelDeactivation,
};
}
export type { ProtectionPhase };