feat(mail-page): polish v3 + shared HalfDonut + status-dot heartbeat-aware
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>
This commit is contained in:
parent
206941e5e1
commit
1dfb0c647c
@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppHeader } from '../../components/AppHeader';
|
||||
import { MailAccountCard } from '../../components/mail/MailAccountCard';
|
||||
import { MailEmptyState } from '../../components/mail/MailEmptyState';
|
||||
import { MailActivityLog } from '../../components/mail/MailActivityLog';
|
||||
import { MailActivityLogBody } from '../../components/mail/MailActivityLog';
|
||||
import { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart';
|
||||
import { MailDistributionChart } from '../../components/mail/MailDistributionChart';
|
||||
import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
|
||||
@ -91,20 +91,28 @@ function MoreInfosSection({
|
||||
expanded,
|
||||
onToggle,
|
||||
blockedByDay,
|
||||
providers,
|
||||
colors,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[];
|
||||
providers: string[];
|
||||
colors: import('../../lib/theme').ColorScheme;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activityExpanded, setActivityExpanded] = useState(false);
|
||||
|
||||
function handleToggle() {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
onToggle();
|
||||
}
|
||||
|
||||
function handleActivityToggle() {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
setActivityExpanded((p) => !p);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@ -170,10 +178,77 @@ function MoreInfosSection({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* Bar-Chart direkt sichtbar */}
|
||||
<MailBlockedByDayChart data={blockedByDay} />
|
||||
|
||||
{/* Nested: Kürzlich blockiert — default collapsed */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surfaceElevated ?? colors.surface,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={handleActivityToggle} activeOpacity={0.85}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 7,
|
||||
backgroundColor: '#fef2f2',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash" size={13} color="#dc2626" />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||
<Text
|
||||
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t('mail.activity_log_title')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.textMuted,
|
||||
marginTop: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t('mail.activity_log_subtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={activityExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{activityExpanded && (
|
||||
<MailActivityLogBody providers={providers} colors={colors} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@ -197,7 +272,6 @@ export default function MailScreen() {
|
||||
const [successVisible, setSuccessVisible] = useState(false);
|
||||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||||
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
|
||||
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
|
||||
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
|
||||
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
|
||||
|
||||
@ -443,25 +517,15 @@ export default function MailScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */}
|
||||
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
|
||||
{hasAccounts && (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<MoreInfosSection
|
||||
expanded={moreInfosExpanded}
|
||||
onToggle={() => setMoreInfosExpanded((p) => !p)}
|
||||
blockedByDay={blockedByDay}
|
||||
colors={colors}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 4. ACTIVITY LOG */}
|
||||
{hasAccounts && (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<MailActivityLog
|
||||
expanded={activityLogExpanded}
|
||||
onToggle={() => setActivityLogExpanded((p) => !p)}
|
||||
providers={distinctProviders}
|
||||
colors={colors}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -142,7 +142,7 @@ export function SheetFieldStack({ fields, intro, children, onComplete }: Props)
|
||||
{field.secureTextEntry ? '••••••••' : field.value}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="pencil-outline" size={14} color={colors.textMuted} />
|
||||
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
|
||||
@ -11,11 +11,11 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
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;
|
||||
@ -411,115 +411,6 @@ function LegendItem({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Half Donut (multi-segment) ────────────────────────────────────────────
|
||||
function HalfDonut({
|
||||
segments,
|
||||
centerValue,
|
||||
centerLabel,
|
||||
}: {
|
||||
segments: { value: number; color: string }[];
|
||||
centerValue: number;
|
||||
centerLabel: string;
|
||||
}) {
|
||||
const colors = useColors();
|
||||
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
|
||||
|
||||
const W = 220;
|
||||
const H = 130;
|
||||
const cx = W / 2;
|
||||
const cy = H - 8;
|
||||
const r = 90;
|
||||
const stroke = 18;
|
||||
|
||||
// Compute cumulative angles in [180, 360]
|
||||
let cumAngle = 180;
|
||||
const arcs = segments.map((seg) => {
|
||||
const startAngle = cumAngle;
|
||||
const endAngle = cumAngle + 180 * (seg.value / total);
|
||||
cumAngle = endAngle;
|
||||
return { ...seg, startAngle, endAngle };
|
||||
});
|
||||
|
||||
const animProgress = useRef(new Animated.Value(0)).current;
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
animProgress.setValue(0);
|
||||
const l = animProgress.addListener(({ value }) => setProgress(value));
|
||||
Animated.timing(animProgress, {
|
||||
toValue: 1,
|
||||
duration: 1100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
return () => animProgress.removeListener(l);
|
||||
}, [centerValue, animProgress]);
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={W} height={H}>
|
||||
{/* Background track */}
|
||||
<Path
|
||||
d={arcPath(cx, cy, r, 180, 360)}
|
||||
stroke={colors.surfaceElevated}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{arcs.map((a, i) => {
|
||||
const animatedEnd =
|
||||
a.startAngle + (a.endAngle - a.startAngle) * progress;
|
||||
if (animatedEnd <= a.startAngle + 0.5) return null;
|
||||
return (
|
||||
<Path
|
||||
key={i}
|
||||
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
|
||||
stroke={a.color}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{centerValue === 0 && (
|
||||
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
|
||||
)}
|
||||
</Svg>
|
||||
|
||||
{/* Center number — exactly centered horizontally + vertically inside semicircle */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: H / 2 + 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
|
||||
{centerValue}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
|
||||
{centerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||
const start = polar(cx, cy, r, startDeg);
|
||||
const end = polar(cx, cy, r, endDeg);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
function polar(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
|
||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
const colors = useColors();
|
||||
|
||||
120
apps/rebreak-native/components/common/HalfDonut.tsx
Normal file
120
apps/rebreak-native/components/common/HalfDonut.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Animated, Easing } from 'react-native';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { useColors } from '../../lib/theme';
|
||||
|
||||
export type HalfDonutSegment = { value: number; color: string };
|
||||
|
||||
type Props = {
|
||||
segments: HalfDonutSegment[];
|
||||
centerValue: number | string;
|
||||
centerLabel: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const W_DEFAULT = 220;
|
||||
const H_DEFAULT = 130;
|
||||
const R = 90;
|
||||
const STROKE = 18;
|
||||
|
||||
function polar(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||
const start = polar(cx, cy, r, startDeg);
|
||||
const end = polar(cx, cy, r, endDeg);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export function HalfDonut({ segments, centerValue, centerLabel, width = W_DEFAULT }: Props) {
|
||||
const colors = useColors();
|
||||
|
||||
const scale = width / W_DEFAULT;
|
||||
const W = width;
|
||||
const H = Math.round(H_DEFAULT * scale);
|
||||
const cx = W / 2;
|
||||
const cy = H - Math.round(8 * scale);
|
||||
const r = Math.round(R * scale);
|
||||
const stroke = Math.round(STROKE * scale);
|
||||
|
||||
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
|
||||
|
||||
let cumAngle = 180;
|
||||
const arcs = segments.map((seg) => {
|
||||
const startAngle = cumAngle;
|
||||
const endAngle = cumAngle + 180 * (seg.value / total);
|
||||
cumAngle = endAngle;
|
||||
return { ...seg, startAngle, endAngle };
|
||||
});
|
||||
|
||||
const animProgress = useRef(new Animated.Value(0)).current;
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const animKey = typeof centerValue === 'number' ? centerValue : centerValue;
|
||||
|
||||
useEffect(() => {
|
||||
animProgress.setValue(0);
|
||||
const l = animProgress.addListener(({ value }) => setProgress(value));
|
||||
Animated.timing(animProgress, {
|
||||
toValue: 1,
|
||||
duration: 1100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
return () => animProgress.removeListener(l);
|
||||
}, [animKey, animProgress]);
|
||||
|
||||
const isEmpty = typeof centerValue === 'number' ? centerValue === 0 : centerValue === '0';
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={W} height={H}>
|
||||
<Path
|
||||
d={arcPath(cx, cy, r, 180, 360)}
|
||||
stroke={colors.surfaceElevated}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{arcs.map((a, i) => {
|
||||
const animatedEnd = a.startAngle + (a.endAngle - a.startAngle) * progress;
|
||||
if (animatedEnd <= a.startAngle + 0.5) return null;
|
||||
return (
|
||||
<Path
|
||||
key={i}
|
||||
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
|
||||
stroke={a.color}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isEmpty && (
|
||||
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
|
||||
)}
|
||||
</Svg>
|
||||
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: H / 2 + 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
|
||||
{centerValue}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
|
||||
{centerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -12,12 +12,10 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfirmAlert } from '../ConfirmAlert';
|
||||
import { EditMailAccountSheet } from './EditMailAccountSheet';
|
||||
import { EditMailTitleSheet } from './EditMailTitleSheet';
|
||||
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
|
||||
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
|
||||
import { useMailConnectionStats } from '../../hooks/useMailStats';
|
||||
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||
import type { BlockedByDayEntry } from '../../hooks/useMailStats';
|
||||
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
@ -33,7 +31,6 @@ type Props = {
|
||||
onEditSuccess: () => void;
|
||||
disconnecting?: boolean;
|
||||
blockedLast30d?: number;
|
||||
connectionBlockedByDay?: BlockedByDayEntry[];
|
||||
};
|
||||
|
||||
function OAuthDisconnectHintModal({
|
||||
@ -186,10 +183,17 @@ type StatusDot = 'live' | 'stale' | 'error' | 'waiting';
|
||||
|
||||
function resolveStatusDot(account: MailAccount): StatusDot {
|
||||
if (account.lastConnectError) return 'error';
|
||||
if (!account.lastScannedAt) return 'waiting';
|
||||
// 'waiting' nur wenn weder ein lebendiger Heartbeat noch ein vergangener Scan
|
||||
// existiert. Bei frisch verbundenen Connections (z.B. OAuth-Outlook nach
|
||||
// ersten 5-30s) hat der Daemon schon einen Heartbeat geschrieben, aber
|
||||
// lastScannedAt bleibt NULL bis die erste Gambling-Mail trifft. Wir wollen
|
||||
// dann 'live' anzeigen, nicht 'waiting'.
|
||||
const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt);
|
||||
const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime();
|
||||
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale';
|
||||
if (!account.lastScannedAt && !heartbeatAlive) return 'waiting';
|
||||
if (account.lastScannedAt) {
|
||||
const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime();
|
||||
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale';
|
||||
}
|
||||
return 'live';
|
||||
}
|
||||
|
||||
@ -252,16 +256,18 @@ export function MailAccountCard({
|
||||
onEditSuccess,
|
||||
disconnecting,
|
||||
blockedLast30d,
|
||||
connectionBlockedByDay,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
const [editPasswordVisible, setEditPasswordVisible] = useState(false);
|
||||
const [editTitleVisible, setEditTitleVisible] = useState(false);
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
|
||||
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
|
||||
const { icon, color } = resolveProviderIcon(account.provider);
|
||||
const { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats(
|
||||
account.id,
|
||||
account.createdAt ?? null,
|
||||
expanded,
|
||||
);
|
||||
|
||||
const isOAuth = isOAuthProvider(account.provider);
|
||||
const isLegend = plan === 'legend';
|
||||
@ -271,10 +277,6 @@ export function MailAccountCard({
|
||||
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
||||
|
||||
function handleToggle() {
|
||||
if (hasError) {
|
||||
setEditPasswordVisible(true);
|
||||
return;
|
||||
}
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
onToggle();
|
||||
}
|
||||
@ -361,9 +363,27 @@ export function MailAccountCard({
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
|
||||
{/* Per-connection bar chart */}
|
||||
<View style={{ paddingHorizontal: 14, paddingTop: 14, paddingBottom: 4 }}>
|
||||
{connectionBlockedByDay && connectionBlockedByDay.length > 0 ? (
|
||||
<MailBlockedByDayChart data={connectionBlockedByDay} />
|
||||
) : (
|
||||
{granularity === 'too-new' ? (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f9fafb',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#525252' }}>
|
||||
{t('mail.account_chart_collecting_title')}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', textAlign: 'center' }}>
|
||||
{t('mail.account_chart_collecting_body')}
|
||||
</Text>
|
||||
</View>
|
||||
) : statsLoading && connStats.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
@ -376,9 +396,11 @@ export function MailAccountCard({
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||
{t('mail.account_chart_unavailable')}
|
||||
{t('mail.loading')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<MailBlockedByDayChart data={connStats} granularity={granularity} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@ -412,7 +434,7 @@ export function MailAccountCard({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Settings sub-sheet */}
|
||||
{/* Settings sub-sheet — inline edit, no nested sheets */}
|
||||
<MailAccountSettingsSheet
|
||||
visible={settingsVisible}
|
||||
account={account}
|
||||
@ -421,8 +443,8 @@ export function MailAccountCard({
|
||||
plan={plan}
|
||||
disconnecting={disconnecting}
|
||||
onClose={() => setSettingsVisible(false)}
|
||||
onEditTitle={() => { setSettingsVisible(false); setEditTitleVisible(true); }}
|
||||
onEditPassword={() => { setSettingsVisible(false); setEditPasswordVisible(true); }}
|
||||
onTitleSaved={handleTitleSaved}
|
||||
onPasswordSaved={onEditSuccess}
|
||||
onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }}
|
||||
onIntervalChanged={onIntervalChanged}
|
||||
/>
|
||||
@ -448,23 +470,6 @@ export function MailAccountCard({
|
||||
onClose={() => setOauthDisconnectHintVisible(false)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{!isOAuth && (
|
||||
<EditMailAccountSheet
|
||||
visible={editPasswordVisible}
|
||||
email={account.email}
|
||||
onClose={() => setEditPasswordVisible(false)}
|
||||
onSuccess={onEditSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditMailTitleSheet
|
||||
visible={editTitleVisible}
|
||||
connectionId={account.id}
|
||||
currentTitle={localTitle}
|
||||
onClose={() => setEditTitleVisible(false)}
|
||||
onSuccess={handleTitleSaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormSheet } from '../FormSheet';
|
||||
import { useMailInterval } from '../../hooks/useMailInterval';
|
||||
import { useMailTitleEdit } from '../../hooks/useMailTitleEdit';
|
||||
import { useMailConnect } from '../../hooks/useMailConnect';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||
|
||||
type EditMode = 'list' | 'edit-title' | 'edit-email' | 'edit-password';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
account: MailAccount;
|
||||
@ -13,8 +25,8 @@ type Props = {
|
||||
plan: 'free' | 'pro' | 'legend';
|
||||
disconnecting?: boolean;
|
||||
onClose: () => void;
|
||||
onEditTitle: () => void;
|
||||
onEditPassword: () => void;
|
||||
onTitleSaved: (newTitle: string | null) => void;
|
||||
onPasswordSaved: () => void;
|
||||
onDisconnectRequest: () => void;
|
||||
onIntervalChanged: () => void;
|
||||
};
|
||||
@ -30,13 +42,11 @@ function domainFromEmail(email: string): string {
|
||||
}
|
||||
|
||||
function SettingsRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
destructive,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress?: () => void;
|
||||
@ -58,12 +68,6 @@ function SettingsRow({
|
||||
borderBottomColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={16}
|
||||
color={destructive ? '#dc2626' : '#737373'}
|
||||
style={{ marginRight: 12, width: 20 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: labelColor }}
|
||||
>
|
||||
@ -90,6 +94,118 @@ function SettingsRow({
|
||||
);
|
||||
}
|
||||
|
||||
function EditView({
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
onSave,
|
||||
onBack,
|
||||
saving,
|
||||
error,
|
||||
secureTextEntry,
|
||||
keyboardType,
|
||||
autoCapitalize,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
secureTextEntry?: boolean;
|
||||
keyboardType?: TextInput['props']['keyboardType'];
|
||||
autoCapitalize?: TextInput['props']['autoCapitalize'];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 4 }}>
|
||||
{/* Back row */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={onBack}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 4 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color="#007AFF" />
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Input */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: error ? 6 : 16,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
secureTextEntry={secureTextEntry}
|
||||
keyboardType={keyboardType ?? 'default'}
|
||||
autoCapitalize={autoCapitalize ?? 'sentences'}
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={onSave}
|
||||
style={{
|
||||
paddingVertical: 13,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.text,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#dc2626',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: saving ? '#d4d4d4' : '#007AFF',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('mail.title_save')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailAccountSettingsSheet({
|
||||
visible,
|
||||
account,
|
||||
@ -98,177 +214,352 @@ export function MailAccountSettingsSheet({
|
||||
plan,
|
||||
disconnecting,
|
||||
onClose,
|
||||
onEditTitle,
|
||||
onEditPassword,
|
||||
onTitleSaved,
|
||||
onPasswordSaved,
|
||||
onDisconnectRequest,
|
||||
onIntervalChanged,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { setInterval, updating } = useMailInterval();
|
||||
const { saveTitle, saving: savingTitle, error: titleError } = useMailTitleEdit();
|
||||
const { connect, connecting: connectingPassword, error: connectError } = useMailConnect();
|
||||
|
||||
const isLegend = plan === 'legend';
|
||||
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
||||
|
||||
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
||||
|
||||
const [mode, setMode] = useState<EditMode>('list');
|
||||
const [titleDraft, setTitleDraft] = useState(localTitle ?? '');
|
||||
const [passwordDraft, setPasswordDraft] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
function handleClose() {
|
||||
setMode('list');
|
||||
setTitleDraft(localTitle ?? '');
|
||||
setPasswordDraft('');
|
||||
setPasswordVisible(false);
|
||||
setLocalError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setMode('list');
|
||||
setLocalError(null);
|
||||
}
|
||||
|
||||
async function handleSetInterval(value: number) {
|
||||
const res = await setInterval(account.id, value);
|
||||
if (res.ok) onIntervalChanged();
|
||||
}
|
||||
|
||||
async function handleSaveTitle() {
|
||||
const ok = await saveTitle(account.id, titleDraft);
|
||||
if (ok) {
|
||||
onTitleSaved(titleDraft.trim() || null);
|
||||
setMode('list');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePassword() {
|
||||
setLocalError(null);
|
||||
const result = await connect({ email: account.email, password: passwordDraft });
|
||||
if (result.ok) {
|
||||
setPasswordDraft('');
|
||||
setPasswordVisible(false);
|
||||
onPasswordSaved();
|
||||
setMode('list');
|
||||
} else {
|
||||
setLocalError(result.error ?? t('mail.connect_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
const sheetTitle = mode === 'list'
|
||||
? displayTitle
|
||||
: mode === 'edit-title'
|
||||
? t('mail.row_title')
|
||||
: mode === 'edit-email'
|
||||
? t('mail.row_email')
|
||||
: t('mail.row_password');
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title={displayTitle}
|
||||
initialHeightPct={0.55}
|
||||
growWithKeyboard={false}
|
||||
onClose={handleClose}
|
||||
title={sheetTitle}
|
||||
initialHeightPct={mode === 'list' ? 0.55 : 0.5}
|
||||
growWithKeyboard={mode !== 'list'}
|
||||
>
|
||||
<View style={{ paddingTop: 8 }}>
|
||||
{/* Bezeichnung */}
|
||||
<SettingsRow
|
||||
icon="pencil-outline"
|
||||
label={t('mail.row_title')}
|
||||
value={localTitle ?? '—'}
|
||||
onPress={onEditTitle}
|
||||
/>
|
||||
|
||||
{/* E-Mail (read-only) */}
|
||||
<SettingsRow
|
||||
icon="mail-outline"
|
||||
label={t('mail.row_email')}
|
||||
value={account.email}
|
||||
/>
|
||||
|
||||
{/* Passwort — nur für IMAP-Accounts */}
|
||||
{!isOAuth && (
|
||||
{mode === 'list' && (
|
||||
<View style={{ paddingTop: 8 }}>
|
||||
{/* Bezeichnung */}
|
||||
<SettingsRow
|
||||
icon="key-outline"
|
||||
label={t('mail.row_password')}
|
||||
value="••••••••"
|
||||
onPress={onEditPassword}
|
||||
label={t('mail.row_title')}
|
||||
value={localTitle ?? '—'}
|
||||
onPress={() => { setTitleDraft(localTitle ?? ''); setMode('edit-title'); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scan-Intervall */}
|
||||
{!isLegend ? (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
{/* E-Mail */}
|
||||
<SettingsRow
|
||||
label={t('mail.row_email')}
|
||||
value={account.email}
|
||||
onPress={!isOAuth ? () => setMode('edit-email') : undefined}
|
||||
/>
|
||||
|
||||
{/* Passwort — nur IMAP */}
|
||||
{!isOAuth && (
|
||||
<SettingsRow
|
||||
label={t('mail.row_password')}
|
||||
value="••••••••"
|
||||
onPress={() => { setPasswordDraft(''); setPasswordVisible(false); setMode('edit-password'); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scan-Intervall */}
|
||||
{!isLegend ? (
|
||||
<View
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
{t('mail.scan_interval_label')}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||
{intervalOptions.map((opt) => {
|
||||
const active = account.scanInterval === opt;
|
||||
const disabled = plan === 'free' || updating === account.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={opt}
|
||||
activeOpacity={0.7}
|
||||
disabled={disabled}
|
||||
onPress={() => handleSetInterval(opt)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
backgroundColor: active ? '#007AFF' : '#f5f5f5',
|
||||
opacity: disabled && !active ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: active ? '#fff' : '#525252',
|
||||
}}
|
||||
>
|
||||
{opt}h
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{plan === 'free' && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#a3a3a3',
|
||||
marginTop: 6,
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t('mail.free_scan_interval_hint')}
|
||||
{t('mail.scan_interval_label')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||
{intervalOptions.map((opt) => {
|
||||
const active = account.scanInterval === opt;
|
||||
const disabled = plan === 'free' || updating === account.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={opt}
|
||||
activeOpacity={0.7}
|
||||
disabled={disabled}
|
||||
onPress={() => handleSetInterval(opt)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
backgroundColor: active ? '#007AFF' : '#f5f5f5',
|
||||
opacity: disabled && !active ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: active ? '#fff' : '#525252',
|
||||
}}
|
||||
>
|
||||
{opt}h
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{plan === 'free' && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#a3a3a3',
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{t('mail.free_scan_interval_hint')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f0fdf4',
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 10,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="flash" size={14} color="#16a34a" style={{ marginRight: 8 }} />
|
||||
<Text
|
||||
style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}
|
||||
>
|
||||
{t('mail.realtime_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ height: 20 }} />
|
||||
|
||||
{/* Verbindung trennen */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={onDisconnectRequest}
|
||||
disabled={disconnecting}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f5f5f5',
|
||||
opacity: disconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
size={16}
|
||||
color="#dc2626"
|
||||
style={{ marginRight: 12, width: 20 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}
|
||||
>
|
||||
{t('mail.row_disconnect')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{mode === 'edit-title' && (
|
||||
<EditView
|
||||
label={t('mail.row_title')}
|
||||
value={titleDraft}
|
||||
onChangeText={setTitleDraft}
|
||||
onSave={handleSaveTitle}
|
||||
onBack={goBack}
|
||||
saving={savingTitle}
|
||||
error={titleError}
|
||||
placeholder={t('mail.title_placeholder')}
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'edit-email' && (
|
||||
<EditView
|
||||
label={t('mail.row_email')}
|
||||
value={account.email}
|
||||
onChangeText={() => {}}
|
||||
onSave={() => {}}
|
||||
onBack={goBack}
|
||||
saving={false}
|
||||
error={t('mail.email_change_not_supported')}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholder={account.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'edit-password' && (
|
||||
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 4 }}>
|
||||
{/* Back row */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={goBack}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 4 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color="#007AFF" />
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}>
|
||||
{t('mail.row_password')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Password input with visibility toggle */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f0fdf4',
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 10,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
marginBottom: (localError ?? connectError) ? 6 : 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="flash" size={14} color="#16a34a" style={{ marginRight: 8 }} />
|
||||
<Text
|
||||
style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={passwordDraft}
|
||||
onChangeText={(v) => { setPasswordDraft(v); setLocalError(null); }}
|
||||
placeholder={t('mail.app_password_placeholder')}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
secureTextEntry={!passwordVisible}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSavePassword}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 13,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#0a0a0a',
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => setPasswordVisible((p) => !p)}
|
||||
hitSlop={8}
|
||||
>
|
||||
{t('mail.realtime_desc')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={18}
|
||||
color="#737373"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<View style={{ height: 20 }} />
|
||||
{(localError ?? connectError) && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#dc2626',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{localError ?? connectError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Verbindung trennen */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={onDisconnectRequest}
|
||||
disabled={disconnecting}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f5f5f5',
|
||||
opacity: disconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
size={16}
|
||||
color="#dc2626"
|
||||
style={{ marginRight: 12, width: 20 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={handleSavePassword}
|
||||
disabled={connectingPassword}
|
||||
>
|
||||
{t('mail.row_disconnect')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: connectingPassword ? '#d4d4d4' : '#007AFF',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{connectingPassword ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('mail.title_save')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</FormSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,21 +54,12 @@ function providerDisplayName(provider: string): string {
|
||||
export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const [activeProvider, setActiveProvider] = useState('all');
|
||||
|
||||
const { results, total, loading, refresh } = useMailResults(expanded, activeProvider);
|
||||
|
||||
const filterOptions = ['all', ...providers];
|
||||
|
||||
function handleToggle() {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
onToggle();
|
||||
}
|
||||
|
||||
function handleProviderFilter(p: string) {
|
||||
setActiveProvider(p);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@ -130,97 +121,121 @@ export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
|
||||
|
||||
{expanded && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||
{/* Provider filter chips */}
|
||||
{filterOptions.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 10, gap: 6 }}
|
||||
>
|
||||
{filterOptions.map((p) => {
|
||||
const active = activeProvider === p;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleProviderFilter(p)}
|
||||
style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
backgroundColor: active ? '#007AFF' : colors.surfaceElevated,
|
||||
borderWidth: active ? 0 : 1,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: active ? '#fff' : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{p === 'all' ? t('mail.filter.all') : providerDisplayName(p)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{loading && results.length === 0 ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{t('mail.loading')}
|
||||
</Text>
|
||||
</View>
|
||||
) : results.length === 0 ? (
|
||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||
<Ionicons name="checkmark-circle-outline" size={28} color={colors.success} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.textMuted,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{t('mail.activity_log_empty')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{results.slice(0, 10).map((item) => (
|
||||
<ActivityItem key={item.id} item={item} t={t} colors={colors} />
|
||||
))}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{total > 10
|
||||
? t('mail.activity_log_more', { count: total - 10 })
|
||||
: t('mail.activity_log_count', { count: total })}
|
||||
</Text>
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={refresh} hitSlop={8}>
|
||||
<Ionicons name="refresh" size={14} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<MailActivityLogBody providers={providers} colors={colors} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the expandable body — used standalone by MoreInfosSection as nested collapsible.
|
||||
*/
|
||||
export function MailActivityLogBody({
|
||||
providers = [],
|
||||
colors,
|
||||
}: {
|
||||
providers?: string[];
|
||||
colors: ReturnType<typeof useColors>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeProvider, setActiveProvider] = useState('all');
|
||||
|
||||
const { results, total, loading, refresh } = useMailResults(true, activeProvider);
|
||||
|
||||
const filterOptions = ['all', ...providers];
|
||||
|
||||
return (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||
{/* Provider filter chips */}
|
||||
{filterOptions.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 10, gap: 6 }}
|
||||
>
|
||||
{filterOptions.map((p) => {
|
||||
const active = activeProvider === p;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => setActiveProvider(p)}
|
||||
style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
backgroundColor: active ? '#007AFF' : colors.surfaceElevated,
|
||||
borderWidth: active ? 0 : 1,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: active ? '#fff' : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{p === 'all' ? t('mail.filter.all') : providerDisplayName(p)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{loading && results.length === 0 ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{t('mail.loading')}
|
||||
</Text>
|
||||
</View>
|
||||
) : results.length === 0 ? (
|
||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||
<Ionicons name="checkmark-circle-outline" size={28} color={colors.success} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.textMuted,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{t('mail.activity_log_empty')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{results.slice(0, 10).map((item) => (
|
||||
<ActivityItem key={item.id} item={item} t={t} colors={colors} />
|
||||
))}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{total > 10
|
||||
? t('mail.activity_log_more', { count: total - 10 })
|
||||
: t('mail.activity_log_count', { count: total })}
|
||||
</Text>
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={refresh} hitSlop={8}>
|
||||
<Ionicons name="refresh" size={14} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityItem({
|
||||
item,
|
||||
t,
|
||||
|
||||
@ -6,6 +6,7 @@ import type { BlockedByDayEntry } from '../../hooks/useMailStats';
|
||||
|
||||
type Props = {
|
||||
data: BlockedByDayEntry[];
|
||||
granularity?: 'day' | 'week' | 'month';
|
||||
};
|
||||
|
||||
const BAR_AREA_HEIGHT = 64;
|
||||
@ -16,7 +17,13 @@ function formatAxisLabel(dateStr: string): string {
|
||||
return `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
}
|
||||
|
||||
export function MailBlockedByDayChart({ data }: Props) {
|
||||
function headingKey(granularity: 'day' | 'week' | 'month'): string {
|
||||
if (granularity === 'week') return 'mail.stats.blocked_per_week_heading';
|
||||
if (granularity === 'month') return 'mail.stats.blocked_per_month_heading';
|
||||
return 'mail.stats.blocked_per_day_heading';
|
||||
}
|
||||
|
||||
export function MailBlockedByDayChart({ data, granularity = 'day' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
|
||||
@ -58,7 +65,7 @@ export function MailBlockedByDayChart({ data }: Props) {
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{t('mail.stats.blocked_per_day_heading')}
|
||||
{t(headingKey(granularity))}
|
||||
</Text>
|
||||
|
||||
{allZero ? (
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import { HalfDonut } from '../common/HalfDonut';
|
||||
import type { BlockedByConnectionEntry } from '../../hooks/useMailStats';
|
||||
|
||||
type Props = {
|
||||
data: BlockedByConnectionEntry[];
|
||||
/** When true: renders as full-width hero card with integrated title row */
|
||||
hero?: boolean;
|
||||
totalBlocked?: number;
|
||||
accountCount?: number;
|
||||
@ -17,17 +16,16 @@ type Props = {
|
||||
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
|
||||
const OTHER_COLOR = '#a3a3a3';
|
||||
|
||||
// Legend cap: show max 3 named entries + optional "others" row
|
||||
const MAX_LEGEND_ENTRIES = 3;
|
||||
|
||||
const R_OUTER = 54;
|
||||
const R_INNER = 34;
|
||||
const CX = 64;
|
||||
const CY = 64;
|
||||
const DONUT_WIDTH = 168;
|
||||
|
||||
// Half-donut: upper semicircle, flat edge at bottom.
|
||||
// Slices sweep from -90° (left) to +90° (right) = 180° total.
|
||||
const HALF_DONUT_START_DEG = -90;
|
||||
function formatCompact(n: number): string {
|
||||
if (n < 1000) return n.toLocaleString();
|
||||
const k = n / 1000;
|
||||
if (k < 10) return `${Math.floor(k * 10) / 10}k+`;
|
||||
return `${Math.floor(k)}k+`;
|
||||
}
|
||||
|
||||
function domainFromEmail(email: string): string {
|
||||
return email.split('@')[1] ?? email;
|
||||
@ -37,45 +35,12 @@ function displayLabel(entry: BlockedByConnectionEntry): string {
|
||||
return entry.title ?? domainFromEmail(entry.email);
|
||||
}
|
||||
|
||||
function polarToXY(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
function arcPath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
rOuter: number,
|
||||
rInner: number,
|
||||
startDeg: number,
|
||||
endDeg: number,
|
||||
): string {
|
||||
const outerStart = polarToXY(cx, cy, rOuter, startDeg);
|
||||
const outerEnd = polarToXY(cx, cy, rOuter, endDeg);
|
||||
const innerEnd = polarToXY(cx, cy, rInner, endDeg);
|
||||
const innerStart = polarToXY(cx, cy, rInner, startDeg);
|
||||
const large = endDeg - startDeg > 180 ? 1 : 0;
|
||||
|
||||
return [
|
||||
`M ${outerStart.x} ${outerStart.y}`,
|
||||
`A ${rOuter} ${rOuter} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y}`,
|
||||
`L ${innerEnd.x} ${innerEnd.y}`,
|
||||
`A ${rInner} ${rInner} 0 ${large} 0 ${innerStart.x} ${innerStart.y}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function MailDistributionChart({ data, hero, totalBlocked, accountCount, isLegend }: Props) {
|
||||
export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
|
||||
// Build donut slices: hard-cap at 3 named entries + "Sonstige" bucket.
|
||||
// ≤3 accounts → show all (no grouping). 4+ → Top-3 + Sonstige.
|
||||
const slices = useMemo(() => {
|
||||
if (data.length === 0 || total === 0) return [];
|
||||
|
||||
@ -87,11 +52,9 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
count: e.count,
|
||||
color: SLICE_COLORS[i] ?? OTHER_COLOR,
|
||||
isOther: false,
|
||||
hiddenCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 4+ connections: Top-3 + Sonstige bucket
|
||||
const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES);
|
||||
const rest = sorted.slice(MAX_LEGEND_ENTRIES);
|
||||
const restCount = rest.reduce((s, e) => s + e.count, 0);
|
||||
@ -102,7 +65,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
count: e.count,
|
||||
color: SLICE_COLORS[i],
|
||||
isOther: false,
|
||||
hiddenCount: 0,
|
||||
}));
|
||||
|
||||
items.push({
|
||||
@ -110,7 +72,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
count: restCount,
|
||||
color: OTHER_COLOR,
|
||||
isOther: true,
|
||||
hiddenCount: restConnectionCount,
|
||||
});
|
||||
|
||||
return items;
|
||||
@ -118,9 +79,11 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
|
||||
if (data.length <= 1 || total === 0) return null;
|
||||
|
||||
let cursor = HALF_DONUT_START_DEG;
|
||||
|
||||
const displayTotal = totalBlocked ?? total;
|
||||
const centerValue = formatCompact(displayTotal);
|
||||
const centerLabel = t('mail.stats.distribution_center_label');
|
||||
|
||||
const segments = slices.map((s) => ({ value: s.count, color: s.color }));
|
||||
|
||||
if (hero) {
|
||||
return (
|
||||
@ -135,7 +98,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
{/* Integrated title row */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -143,20 +105,19 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontFamily: 'Nunito_800ExtraBold',
|
||||
color: colors.error,
|
||||
lineHeight: 26,
|
||||
}}
|
||||
>
|
||||
{displayTotal.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('mail.stats.distribution_heading')}
|
||||
</Text>
|
||||
|
||||
{/* Live / Scheduled pill */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -188,24 +149,13 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Donut + Legend */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
<Svg width={128} height={68} viewBox="0 0 128 68">
|
||||
{slices.map((slice) => {
|
||||
const sweep = (slice.count / total) * 180;
|
||||
const startDeg = cursor;
|
||||
cursor += sweep;
|
||||
return (
|
||||
<Path
|
||||
key={slice.label}
|
||||
d={arcPath(CX, CY, R_OUTER, R_INNER, startDeg, startDeg + sweep)}
|
||||
fill={slice.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
|
||||
</Svg>
|
||||
|
||||
<HalfDonut
|
||||
segments={segments}
|
||||
centerValue={centerValue}
|
||||
centerLabel={centerLabel}
|
||||
width={DONUT_WIDTH}
|
||||
/>
|
||||
<View style={{ flex: 1, gap: 5 }}>
|
||||
{slices.map((slice) => (
|
||||
<LegendRow key={slice.label} slice={slice} colors={colors} />
|
||||
@ -216,7 +166,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
);
|
||||
}
|
||||
|
||||
// Standard (non-hero) card — kept for potential reuse
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@ -243,22 +192,12 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
<Svg width={128} height={68} viewBox="0 0 128 68">
|
||||
{slices.map((slice) => {
|
||||
const sweep = (slice.count / total) * 180;
|
||||
const startDeg = cursor;
|
||||
cursor += sweep;
|
||||
return (
|
||||
<Path
|
||||
key={slice.label}
|
||||
d={arcPath(CX, CY, R_OUTER, R_INNER, startDeg, startDeg + sweep)}
|
||||
fill={slice.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
|
||||
</Svg>
|
||||
|
||||
<HalfDonut
|
||||
segments={segments}
|
||||
centerValue={centerValue}
|
||||
centerLabel={centerLabel}
|
||||
width={DONUT_WIDTH}
|
||||
/>
|
||||
<View style={{ flex: 1, gap: 6 }}>
|
||||
{slices.map((slice) => (
|
||||
<LegendRow key={slice.label} slice={slice} colors={colors} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export type BlockedByDayEntry = {
|
||||
@ -20,6 +20,106 @@ type MailStatsState = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type ConnectionAgeGranularity = 'too-new' | 'day' | 'week' | 'month';
|
||||
|
||||
function resolveGranularity(createdAt: string | null | undefined): ConnectionAgeGranularity {
|
||||
if (!createdAt) return 'day';
|
||||
const ageMs = Date.now() - new Date(createdAt).getTime();
|
||||
const ageH = ageMs / 3_600_000;
|
||||
if (ageH < 24) return 'too-new';
|
||||
const ageD = ageH / 24;
|
||||
if (ageD <= 14) return 'day';
|
||||
if (ageD <= 90) return 'week';
|
||||
return 'month';
|
||||
}
|
||||
|
||||
type ConnectionStatsState = {
|
||||
data: BlockedByDayEntry[];
|
||||
granularity: ConnectionAgeGranularity;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches per-connection blocked-by-day stats lazily (only when `enabled` flips true).
|
||||
* Adaptive heading granularity based on connection age:
|
||||
* < 24h → 'too-new' (empty-state, data collection in progress)
|
||||
* 1-14d → 'day' (30-day bars)
|
||||
* 15-90d → 'week' (client-aggregated into week buckets)
|
||||
* > 90d → 'month' (client-aggregated into month buckets)
|
||||
*/
|
||||
export function useMailConnectionStats(
|
||||
connectionId: string,
|
||||
createdAt: string | null | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const fetchedRef = useRef(false);
|
||||
const [state, setState] = useState<ConnectionStatsState>({
|
||||
data: [],
|
||||
granularity: resolveGranularity(createdAt),
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const granularity = resolveGranularity(createdAt);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!enabled || !connectionId) return;
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const raw = await apiFetch<BlockedByDayEntry[]>(
|
||||
`/api/mail/stats/blocked-by-day?days=30&connectionId=${connectionId}`,
|
||||
);
|
||||
|
||||
let data = raw;
|
||||
|
||||
if (granularity === 'week') {
|
||||
data = aggregateToWeeks(raw);
|
||||
} else if (granularity === 'month') {
|
||||
data = aggregateToMonths(raw);
|
||||
}
|
||||
|
||||
setState({ data, granularity, loading: false });
|
||||
} catch {
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
}, [enabled, connectionId, granularity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
fetch();
|
||||
}, [enabled, fetch]);
|
||||
|
||||
return { ...state, refresh: fetch };
|
||||
}
|
||||
|
||||
function aggregateToWeeks(entries: BlockedByDayEntry[]): BlockedByDayEntry[] {
|
||||
const buckets: Map<string, number> = new Map();
|
||||
for (const e of entries) {
|
||||
const d = new Date(e.date + 'T00:00:00');
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
const monday = new Date(d);
|
||||
monday.setDate(diff);
|
||||
const key = monday.toISOString().slice(0, 10);
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + e.count);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
}
|
||||
|
||||
function aggregateToMonths(entries: BlockedByDayEntry[]): BlockedByDayEntry[] {
|
||||
const buckets: Map<string, number> = new Map();
|
||||
for (const e of entries) {
|
||||
const key = e.date.slice(0, 7);
|
||||
buckets.set(key, (buckets.get(key) ?? 0) + e.count);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
}
|
||||
|
||||
export function useMailStats(enabled: boolean) {
|
||||
const [state, setState] = useState<MailStatsState>({
|
||||
blockedByDay: [],
|
||||
|
||||
@ -18,6 +18,8 @@ export type MailAccount = {
|
||||
lastConnectError?: string | null;
|
||||
lastConnectErrorAt?: string | null;
|
||||
lastIdleHeartbeatAt?: string | null;
|
||||
/** ISO-date — present when backend includes it. Used for adaptive chart granularity. */
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type DailyStat = {
|
||||
|
||||
@ -457,7 +457,10 @@
|
||||
"stats": {
|
||||
"blocked_per_day_heading": "Blockiert — letzte 30 Tage",
|
||||
"blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche",
|
||||
"blocked_per_week_heading": "Blockiert — letzte Wochen",
|
||||
"blocked_per_month_heading": "Blockiert — letzte Monate",
|
||||
"distribution_heading": "Verteilung nach Postfach",
|
||||
"distribution_center_label": "insgesamt",
|
||||
"distribution_other": "Sonstige",
|
||||
"distribution_other_n": "+%{n} weitere",
|
||||
"empty_title": "Noch keine Mails blockiert",
|
||||
@ -479,7 +482,10 @@
|
||||
"disconnect_hint_title": "Verbindung getrennt",
|
||||
"disconnect_hint_body": "Die Tokens wurden aus unserer Datenbank gelöscht. Microsoft unterstützt leider keinen serverseitigen Widerruf durch Drittanbieter-Apps. Für eine vollständige Entfernung der Rebreak-Berechtigung in deinem Microsoft-Konto: account.microsoft.com → Sicherheit → Berechtigungen für Apps → Rebreak suchen → Entfernen.",
|
||||
"disconnect_hint_open_ms": "Microsoft öffnen"
|
||||
}
|
||||
},
|
||||
"account_chart_collecting_title": "Daten werden gesammelt",
|
||||
"account_chart_collecting_body": "Auswertung verfügbar nach 24h",
|
||||
"email_change_not_supported": "E-Mail-Änderung kommt bald"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
|
||||
@ -457,7 +457,10 @@
|
||||
"stats": {
|
||||
"blocked_per_day_heading": "Blocked — last 30 days",
|
||||
"blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week",
|
||||
"blocked_per_week_heading": "Blocked — recent weeks",
|
||||
"blocked_per_month_heading": "Blocked — recent months",
|
||||
"distribution_heading": "Distribution by mailbox",
|
||||
"distribution_center_label": "total",
|
||||
"distribution_other": "Others",
|
||||
"distribution_other_n": "+%{n} more",
|
||||
"empty_title": "No mails blocked yet",
|
||||
@ -479,7 +482,10 @@
|
||||
"disconnect_hint_title": "Connection removed",
|
||||
"disconnect_hint_body": "The tokens have been deleted from our database. Unfortunately Microsoft does not support server-side revocation by third-party apps. To fully remove Rebreak's permission from your Microsoft account: account.microsoft.com → Security → App permissions → find Rebreak → Remove.",
|
||||
"disconnect_hint_open_ms": "Open Microsoft"
|
||||
}
|
||||
},
|
||||
"account_chart_collecting_title": "Collecting data",
|
||||
"account_chart_collecting_body": "Analysis available after 24h",
|
||||
"email_change_not_supported": "Email change coming soon"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@ -480,7 +480,17 @@ async function runSession(conn) {
|
||||
clearConnectionError(conn.id).catch(() => {}),
|
||||
]);
|
||||
|
||||
await imap.getMailboxLock("INBOX");
|
||||
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
|
||||
// wenn der Server in einen ungültigen Zustand kommt — die Session
|
||||
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein
|
||||
// imap.close() schickt. Timeout-wrap macht das Failure-Mode explizit
|
||||
// → Auth-Retry-Loop greift sauber.
|
||||
await Promise.race([
|
||||
imap.getMailboxLock("INBOX"),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("getMailboxLock timeout (30s)")), 30_000),
|
||||
),
|
||||
]);
|
||||
|
||||
// Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt
|
||||
if (!conn.consentAt) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user