chahinebrini 5c6fa3d45b feat(native/blocker): underlined Seiten/Mails tabs + per-type counter
Top-tabs above the custom-domains grid: Seiten (web) and Mails (mail_*).
2px underline highlight in colors.brandOrange for the active tab, the
muted label otherwise — matches the community/feed tab style we already
use. Pill segmented control would have needed extra inset math for two
tabs without adding clarity.

- DomainGrid filters items by the active tab. Tab-specific empty-state
  copy and icon (mail-outline for the Mails tab) so the empty Mails tab
  doesn't read like a broken Web view.
- mail_display_name tiles hide the submit-to-global button entirely —
  matches the v1.0 backend lock; the user can't accidentally tap into a
  400 from the API.
- useCustomDomains exposes countsByType + limits. Provisional client-
  side estimation until the new API response shape (extended in the
  parallel backend commit f2b81ee) is wired through — same TS shape,
  so dropping the estimation is a one-line swap when ready.
- AddDomainSheet picks up initialType so tapping "+" while the Mails tab
  is active opens the sheet pre-selected to E-Mail. Plan-limit error
  handling maps WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED to the right
  per-bucket message.

i18n: tabs_web / tabs_mail / count_label / error_web_limit_reached /
error_mail_limit_reached / empty_web / empty_mail across DE/EN/FR with
%{var} placeholders.
2026-05-16 02:03:41 +02:00

521 lines
19 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { 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 type { CountsByType, LimitsByType } from '../../hooks/useCustomDomains';
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.
// 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);
// Tab-State
const [activeTab, setActiveTab] = useState<'web' | 'mail'>('web');
// 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);
// `accessibility_pending` = die a11y-Berechtigung fehlt noch und wir haben
// grad die System-Settings geöffnet → das IST das Feedback. Kein Fehler-
// Modal (sonst Modal-Loop bei jedem Tap). a11y wird nur beim ersten
// Einrichten geholt; danach ist das hier ein 1-Tap-Arm ohne Dialog.
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;
// Schutz-Filter ist aus, sollte aber an sein → Reaktivierung setzt NUR den
// VPN/Filter wieder (kein a11y-Prompt — das passiert nur beim ersten Mal).
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]);
// ─── 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}
/>
{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 — nur wenn Cooldown läuft */}
{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: Custom-Domain-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>
)}
{/* Top-Tabs: Seiten / Mails */}
<BlockerTabBar
activeTab={activeTab}
onTabChange={setActiveTab}
countsByType={countsByType}
limits={limits}
/>
{/* Domain Grid mit inline + Button neben SlotPill */}
<View style={{ marginTop: 8 }}>
<DomainGrid
domains={domains}
tier={tier}
activeTab={activeTab}
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}
initialType={activeTab}
onClose={() => {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (pattern, kind) => {
const result = await addDomain(pattern, kind);
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>
);
}
// ─── BlockerTabBar ────────────────────────────────────────────────────────────
function BlockerTabBar({
activeTab,
onTabChange,
countsByType,
limits,
}: {
activeTab: 'web' | 'mail';
onTabChange: (tab: 'web' | 'mail') => void;
countsByType: CountsByType;
limits: LimitsByType;
}) {
const { t } = useTranslation();
const colors = useColors();
const tabs: { key: 'web' | 'mail'; label: string; count: number; max: number }[] = [
{ key: 'web', label: t('blocker.tabs_web'), count: countsByType.web, max: limits.web },
{ key: 'mail', label: t('blocker.tabs_mail'), count: countsByType.mail, max: limits.mail },
];
return (
<View
style={{
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.border,
marginBottom: 4,
}}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.key;
const atMax = tab.count >= tab.max;
const badgeColor = atMax ? colors.error : colors.textMuted;
return (
<TouchableOpacity
key={tab.key}
onPress={() => onTabChange(tab.key)}
activeOpacity={0.7}
style={{
flex: 1,
alignItems: 'center',
paddingBottom: 10,
paddingTop: 4,
borderBottomWidth: 2,
borderBottomColor: isActive ? colors.brandOrange : 'transparent',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontSize: 14,
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
color: isActive ? colors.text : colors.textMuted,
}}
>
{tab.label}
</Text>
<View
style={{
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 999,
backgroundColor: atMax ? '#fee2e2' : colors.surfaceElevated,
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: badgeColor,
}}
>
{t('blocker.count_label', { count: tab.count, max: tab.max })}
</Text>
</View>
</View>
</TouchableOpacity>
);
})}
</View>
);
}