User-Feedback nach Live-Test:
Frontend (mail page):
- HalfDonut als shared component in components/common/HalfDonut.tsx
extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt
jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der
Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick.
Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend
rechts daneben sitzt).
- Donut zeigt Total in der Mitte mit kompaktem Format:
< 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+"
Headline-Zahl oben links entfällt — Total ist im Donut-Center.
- "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible
zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested
Collapsible "Kürzlich blockiert" darunter (default zu).
- Account-Card Expanded: per-Connection-Bar-Chart mit adaptive
Granularität nach Connection-Age:
· <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h"
· 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day
?connectionId=)
· 15-90d → Week-Buckets (client-aggregiert)
· >90d → Month-Buckets (client-aggregiert)
- Settings-Sheet komplett refactored: State-Machine `mode: 'list' |
'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline-
Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern).
Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat).
- Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows
auf chevron-forward (Memory-Konvention).
Frontend (MailAccountCard status fix):
- resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting"
wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt:
"waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan
existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach
Connect) zeigen direkt "live".
- Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook
obwohl Daemon-Log "connected, auth=xoauth2" zeigt.
Backend (imap-idle daemon):
- getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt.
- Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos
hängt nach erfolgreichem connect — die Session bleibt offen ohne
Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt.
Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift
sauber + last_connect_error mit klarem Text (statt stiller Hänger).
- Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach
Deploy in Logs (mo).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
ScrollView,
|
||
Animated,
|
||
ActivityIndicator,
|
||
Easing,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useTranslation } from 'react-i18next';
|
||
import type { ProtectionState } from '../../lib/protection';
|
||
import { apiFetch } from '../../lib/api';
|
||
import { useColors } from '../../lib/theme';
|
||
import { FormSheet } from '../FormSheet';
|
||
import { HalfDonut } from '../common/HalfDonut';
|
||
|
||
type Props = {
|
||
visible: boolean;
|
||
state: ProtectionState;
|
||
onClose: () => void;
|
||
onRequestDeactivation: () => void;
|
||
onTalkToLyra: () => void;
|
||
};
|
||
|
||
type StatsResponse = {
|
||
current: number;
|
||
weeklyAdded: number;
|
||
monthlyAdded: number;
|
||
history: { label: string; count: number }[];
|
||
submissions: { inVote: number; inReview: number };
|
||
mySubmissions: { active: number; inVote: number; inReview: number };
|
||
avgPerUser: number;
|
||
avgApprovalWaitDays: number;
|
||
};
|
||
|
||
// Brand colors
|
||
const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
|
||
const SEG_ACTIVE = '#16a34a';
|
||
const SEG_VOTE = '#3b82f6';
|
||
const SEG_REVIEW = '#f59e0b';
|
||
|
||
export function ProtectionDetailsSheet({
|
||
visible,
|
||
state,
|
||
onClose,
|
||
onRequestDeactivation,
|
||
}: Props) {
|
||
const { t, i18n } = useTranslation();
|
||
const colors = useColors();
|
||
const insets = useSafeAreaInsets();
|
||
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
||
|
||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||
const [loadingStats, setLoadingStats] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
let alive = true;
|
||
setLoadingStats(true);
|
||
apiFetch<StatsResponse>('/api/blocklist/stats')
|
||
.then((res) => { if (alive) setStats(res); })
|
||
.catch(() => { /* silent */ })
|
||
.finally(() => { if (alive) setLoadingStats(false); });
|
||
return () => { alive = false; };
|
||
}, [visible]);
|
||
|
||
const globalCount = stats?.current ?? state.blocklistCount;
|
||
const weeklyAdded = stats?.weeklyAdded ?? 0;
|
||
const monthlyAdded = stats?.monthlyAdded ?? 0;
|
||
const myActive = stats?.mySubmissions?.active ?? 0;
|
||
const myInVote = stats?.mySubmissions?.inVote ?? 0;
|
||
const myInReview = stats?.mySubmissions?.inReview ?? 0;
|
||
const avgPerUser = stats?.avgPerUser ?? 0;
|
||
const avgWait = stats?.avgApprovalWaitDays ?? 0;
|
||
|
||
return (
|
||
<FormSheet
|
||
visible={visible}
|
||
onClose={onClose}
|
||
title={t('blocker.details_title')}
|
||
initialHeightPct={0.75}
|
||
minHeightPct={0.3}
|
||
safeAreaBottom={false}
|
||
>
|
||
<ScrollView
|
||
style={{ flex: 1 }}
|
||
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 32, gap: 18 }}
|
||
showsVerticalScrollIndicator
|
||
>
|
||
{loadingStats && !stats ? (
|
||
<View style={{ padding: 40, alignItems: 'center' }}>
|
||
<ActivityIndicator color="#737373" />
|
||
</View>
|
||
) : null}
|
||
|
||
{/* HERO – Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */}
|
||
<View
|
||
style={{
|
||
padding: 20,
|
||
borderRadius: 16,
|
||
backgroundColor: '#0a0a0a',
|
||
gap: 14,
|
||
}}
|
||
>
|
||
<View>
|
||
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_700Bold', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||
{t('blocker.kpi_global_label')}
|
||
</Text>
|
||
<AnimatedCounter
|
||
value={globalCount}
|
||
locale={localeTag}
|
||
durationMs={2400}
|
||
style={{ fontSize: 38, color: HERO_COLOR, fontFamily: 'Nunito_900Black', letterSpacing: -1, marginTop: 2 }}
|
||
/>
|
||
</View>
|
||
|
||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||
<DeltaBadge value={weeklyAdded} label={t('blocker.delta_week')} locale={localeTag} />
|
||
<DeltaBadge value={monthlyAdded} label={t('blocker.delta_month')} locale={localeTag} />
|
||
</View>
|
||
</View>
|
||
|
||
{/* SUBMISSIONS – Half Donut mit center-number + center-legend */}
|
||
<View
|
||
style={{
|
||
padding: 18,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
backgroundColor: colors.bg,
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<View>
|
||
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||
{t('blocker.kpi_submissions_title')}
|
||
</Text>
|
||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
|
||
{t('blocker.kpi_submissions_subtitle')}
|
||
</Text>
|
||
</View>
|
||
|
||
<HalfDonut
|
||
segments={[
|
||
{ value: myActive, color: SEG_ACTIVE },
|
||
{ value: myInVote, color: SEG_VOTE },
|
||
{ value: myInReview, color: SEG_REVIEW },
|
||
]}
|
||
centerValue={myActive + myInVote + myInReview}
|
||
centerLabel={t('blocker.kpi_my_submissions')}
|
||
/>
|
||
|
||
{/* Centered Legend */}
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
alignItems: 'flex-start',
|
||
gap: 14,
|
||
marginTop: 4,
|
||
}}
|
||
>
|
||
<LegendItem color={SEG_ACTIVE} label={t('blocker.kpi_status_active')} value={myActive} />
|
||
<LegendItem color={SEG_VOTE} label={t('blocker.kpi_status_vote')} value={myInVote} />
|
||
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
|
||
</View>
|
||
</View>
|
||
|
||
{/* AVG KPIs – kleiner */}
|
||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||
<KpiCard
|
||
icon="person-circle-outline"
|
||
label={t('blocker.kpi_avg_per_user')}
|
||
value={avgPerUser}
|
||
locale={localeTag}
|
||
decimals={1}
|
||
/>
|
||
<KpiCard
|
||
icon="time-outline"
|
||
label={t('blocker.kpi_avg_wait')}
|
||
value={avgWait}
|
||
locale={localeTag}
|
||
decimals={1}
|
||
suffix={t('blocker.kpi_days_suffix')}
|
||
/>
|
||
</View>
|
||
|
||
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
|
||
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
|
||
<View
|
||
style={{
|
||
alignSelf: 'stretch',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
flex: 1,
|
||
fontSize: 11,
|
||
fontFamily: 'Nunito_700Bold',
|
||
color: colors.textMuted,
|
||
letterSpacing: 0.5,
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
{t('blocker.faq_heading')}
|
||
</Text>
|
||
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
|
||
</View>
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<FaqItem
|
||
key={n}
|
||
question={t(`blocker.faq${n}_q`)}
|
||
answer={t(`blocker.faq${n}_a`)}
|
||
/>
|
||
))}
|
||
</View>
|
||
|
||
{/* "Schutz deaktivieren" – outline button: TouchableOpacity=card, inner View=flex-row */}
|
||
<TouchableOpacity
|
||
onPress={onRequestDeactivation}
|
||
activeOpacity={0.75}
|
||
style={{ marginTop: 4 }}
|
||
>
|
||
<View style={{
|
||
paddingVertical: 14,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 12,
|
||
borderWidth: 1.5,
|
||
borderColor: HERO_COLOR,
|
||
backgroundColor: colors.surface,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
}}>
|
||
<Ionicons name="lock-open-outline" size={18} color={HERO_COLOR} />
|
||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
|
||
{t('blocker.more_info_title')}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</FormSheet>
|
||
);
|
||
}
|
||
|
||
// ─── Animated Counter ──────────────────────────────────────────────────────
|
||
function AnimatedCounter({
|
||
value,
|
||
locale,
|
||
decimals = 0,
|
||
durationMs = 1200,
|
||
style,
|
||
}: {
|
||
value: number;
|
||
locale: string;
|
||
decimals?: number;
|
||
durationMs?: number;
|
||
style?: any;
|
||
}) {
|
||
const anim = useRef(new Animated.Value(0)).current;
|
||
const [display, setDisplay] = useState(0);
|
||
|
||
useEffect(() => {
|
||
anim.setValue(0);
|
||
const listener = anim.addListener(({ value: v }) => {
|
||
setDisplay(v * value);
|
||
});
|
||
Animated.timing(anim, {
|
||
toValue: 1,
|
||
duration: durationMs,
|
||
easing: Easing.out(Easing.cubic),
|
||
useNativeDriver: false,
|
||
}).start();
|
||
return () => anim.removeListener(listener);
|
||
}, [value, anim, durationMs]);
|
||
|
||
const formatted = display.toLocaleString(locale, {
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals,
|
||
});
|
||
|
||
return <Text style={style}>{formatted}</Text>;
|
||
}
|
||
|
||
// ─── Delta Badge (e.g. "+25 diese Woche ↗") ────────────────────────────────
|
||
function DeltaBadge({
|
||
value,
|
||
label,
|
||
locale,
|
||
}: {
|
||
value: number;
|
||
label: string;
|
||
locale: string;
|
||
}) {
|
||
const formatted = `+${value.toLocaleString(locale)}`;
|
||
return (
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 12,
|
||
borderRadius: 10,
|
||
backgroundColor: '#1f1f1f',
|
||
borderWidth: 1,
|
||
borderColor: '#262626',
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 12,
|
||
backgroundColor: 'rgba(22,163,74,0.15)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Ionicons name="arrow-up" size={14} color="#22c55e" />
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ fontSize: 13, color: '#fff', fontFamily: 'Nunito_900Black', letterSpacing: -0.3 }}>
|
||
{formatted}
|
||
</Text>
|
||
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
|
||
{label}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── KPI Card (small) ──────────────────────────────────────────────────────
|
||
function KpiCard({
|
||
icon,
|
||
label,
|
||
value,
|
||
locale,
|
||
decimals = 0,
|
||
suffix,
|
||
}: {
|
||
icon: any;
|
||
label: string;
|
||
value: number;
|
||
locale: string;
|
||
decimals?: number;
|
||
suffix?: string;
|
||
}) {
|
||
const colors = useColors();
|
||
return (
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
padding: 12,
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
backgroundColor: colors.surface,
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||
<Ionicons name={icon} size={14} color={colors.textMuted} />
|
||
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
|
||
{label}
|
||
</Text>
|
||
</View>
|
||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 4 }}>
|
||
<AnimatedCounter
|
||
value={value}
|
||
locale={locale}
|
||
decimals={decimals}
|
||
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.3 }}
|
||
/>
|
||
{suffix ? (
|
||
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Legend Item (compact, centered row) ───────────────────────────────────
|
||
function LegendItem({
|
||
color,
|
||
label,
|
||
value,
|
||
}: {
|
||
color: string;
|
||
label: string;
|
||
value: number;
|
||
}) {
|
||
const colors = useColors();
|
||
return (
|
||
<View style={{ alignItems: 'center', gap: 4 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
|
||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
|
||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
|
||
</View>
|
||
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{label}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
|
||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||
const colors = useColors();
|
||
const [open, setOpen] = useState(false);
|
||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
Animated.timing(rotateAnim, {
|
||
toValue: open ? 1 : 0,
|
||
duration: 200,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
}, [open, rotateAnim]);
|
||
|
||
const rotate = rotateAnim.interpolate({
|
||
inputRange: [0, 1],
|
||
outputRange: ['0deg', '180deg'],
|
||
});
|
||
|
||
return (
|
||
<View
|
||
style={{
|
||
alignSelf: 'stretch',
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
backgroundColor: colors.bg,
|
||
}}
|
||
>
|
||
<TouchableOpacity
|
||
onPress={() => setOpen((v) => !v)}
|
||
activeOpacity={0.75}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
|
||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
|
||
{question}
|
||
</Text>
|
||
</View>
|
||
<Animated.View
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: colors.surfaceElevated,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transform: [{ rotate }],
|
||
}}
|
||
>
|
||
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
|
||
</Animated.View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
{open && (
|
||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
|
||
{answer}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|