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 { AppHeader } from '../../components/AppHeader';
|
||||||
import { MailAccountCard } from '../../components/mail/MailAccountCard';
|
import { MailAccountCard } from '../../components/mail/MailAccountCard';
|
||||||
import { MailEmptyState } from '../../components/mail/MailEmptyState';
|
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 { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart';
|
||||||
import { MailDistributionChart } from '../../components/mail/MailDistributionChart';
|
import { MailDistributionChart } from '../../components/mail/MailDistributionChart';
|
||||||
import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
|
import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
|
||||||
@ -91,20 +91,28 @@ function MoreInfosSection({
|
|||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
blockedByDay,
|
blockedByDay,
|
||||||
|
providers,
|
||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[];
|
blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[];
|
||||||
|
providers: string[];
|
||||||
colors: import('../../lib/theme').ColorScheme;
|
colors: import('../../lib/theme').ColorScheme;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [activityExpanded, setActivityExpanded] = useState(false);
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleActivityToggle() {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
setActivityExpanded((p) => !p);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -170,10 +178,77 @@ function MoreInfosSection({
|
|||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: colors.border,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 12,
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Bar-Chart direkt sichtbar */}
|
||||||
<MailBlockedByDayChart data={blockedByDay} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -197,7 +272,6 @@ export default function MailScreen() {
|
|||||||
const [successVisible, setSuccessVisible] = useState(false);
|
const [successVisible, setSuccessVisible] = useState(false);
|
||||||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||||||
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
|
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
|
||||||
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
|
|
||||||
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
|
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
|
||||||
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
|
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -443,25 +517,15 @@ export default function MailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */}
|
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
|
||||||
{hasAccounts && (
|
{hasAccounts && (
|
||||||
<View style={{ marginTop: 14 }}>
|
<View style={{ marginTop: 14 }}>
|
||||||
<MoreInfosSection
|
<MoreInfosSection
|
||||||
expanded={moreInfosExpanded}
|
expanded={moreInfosExpanded}
|
||||||
onToggle={() => setMoreInfosExpanded((p) => !p)}
|
onToggle={() => setMoreInfosExpanded((p) => !p)}
|
||||||
blockedByDay={blockedByDay}
|
blockedByDay={blockedByDay}
|
||||||
colors={colors}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 4. ACTIVITY LOG */}
|
|
||||||
{hasAccounts && (
|
|
||||||
<View style={{ marginTop: 14 }}>
|
|
||||||
<MailActivityLog
|
|
||||||
expanded={activityLogExpanded}
|
|
||||||
onToggle={() => setActivityLogExpanded((p) => !p)}
|
|
||||||
providers={distinctProviders}
|
providers={distinctProviders}
|
||||||
|
colors={colors}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export function SheetFieldStack({ fields, intro, children, onComplete }: Props)
|
|||||||
{field.secureTextEntry ? '••••••••' : field.value}
|
{field.secureTextEntry ? '••••••••' : field.value}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="pencil-outline" size={14} color={colors.textMuted} />
|
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,11 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
|
||||||
import type { ProtectionState } from '../../lib/protection';
|
import type { ProtectionState } from '../../lib/protection';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
import { HalfDonut } from '../common/HalfDonut';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
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) ─────────────────────
|
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
|
||||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
const colors = useColors();
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConfirmAlert } from '../ConfirmAlert';
|
import { ConfirmAlert } from '../ConfirmAlert';
|
||||||
import { EditMailAccountSheet } from './EditMailAccountSheet';
|
|
||||||
import { EditMailTitleSheet } from './EditMailTitleSheet';
|
|
||||||
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
|
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
|
||||||
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
|
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
|
||||||
|
import { useMailConnectionStats } from '../../hooks/useMailStats';
|
||||||
import type { MailAccount } from '../../hooks/useMailStatus';
|
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||||
import type { BlockedByDayEntry } from '../../hooks/useMailStats';
|
|
||||||
|
|
||||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
@ -33,7 +31,6 @@ type Props = {
|
|||||||
onEditSuccess: () => void;
|
onEditSuccess: () => void;
|
||||||
disconnecting?: boolean;
|
disconnecting?: boolean;
|
||||||
blockedLast30d?: number;
|
blockedLast30d?: number;
|
||||||
connectionBlockedByDay?: BlockedByDayEntry[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function OAuthDisconnectHintModal({
|
function OAuthDisconnectHintModal({
|
||||||
@ -186,10 +183,17 @@ type StatusDot = 'live' | 'stale' | 'error' | 'waiting';
|
|||||||
|
|
||||||
function resolveStatusDot(account: MailAccount): StatusDot {
|
function resolveStatusDot(account: MailAccount): StatusDot {
|
||||||
if (account.lastConnectError) return 'error';
|
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 heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt);
|
||||||
const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime();
|
if (!account.lastScannedAt && !heartbeatAlive) return 'waiting';
|
||||||
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale';
|
if (account.lastScannedAt) {
|
||||||
|
const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime();
|
||||||
|
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale';
|
||||||
|
}
|
||||||
return 'live';
|
return 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,16 +256,18 @@ export function MailAccountCard({
|
|||||||
onEditSuccess,
|
onEditSuccess,
|
||||||
disconnecting,
|
disconnecting,
|
||||||
blockedLast30d,
|
blockedLast30d,
|
||||||
connectionBlockedByDay,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||||
const [editPasswordVisible, setEditPasswordVisible] = useState(false);
|
|
||||||
const [editTitleVisible, setEditTitleVisible] = useState(false);
|
|
||||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||||
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
|
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
|
||||||
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
|
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
|
||||||
const { icon, color } = resolveProviderIcon(account.provider);
|
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 isOAuth = isOAuthProvider(account.provider);
|
||||||
const isLegend = plan === 'legend';
|
const isLegend = plan === 'legend';
|
||||||
@ -271,10 +277,6 @@ export function MailAccountCard({
|
|||||||
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
if (hasError) {
|
|
||||||
setEditPasswordVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
@ -361,9 +363,27 @@ export function MailAccountCard({
|
|||||||
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
|
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
|
||||||
{/* Per-connection bar chart */}
|
{/* Per-connection bar chart */}
|
||||||
<View style={{ paddingHorizontal: 14, paddingTop: 14, paddingBottom: 4 }}>
|
<View style={{ paddingHorizontal: 14, paddingTop: 14, paddingBottom: 4 }}>
|
||||||
{connectionBlockedByDay && connectionBlockedByDay.length > 0 ? (
|
{granularity === 'too-new' ? (
|
||||||
<MailBlockedByDayChart data={connectionBlockedByDay} />
|
<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
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@ -376,9 +396,11 @@ export function MailAccountCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||||
{t('mail.account_chart_unavailable')}
|
{t('mail.loading')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<MailBlockedByDayChart data={connStats} granularity={granularity} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -412,7 +434,7 @@ export function MailAccountCard({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Settings sub-sheet */}
|
{/* Settings sub-sheet — inline edit, no nested sheets */}
|
||||||
<MailAccountSettingsSheet
|
<MailAccountSettingsSheet
|
||||||
visible={settingsVisible}
|
visible={settingsVisible}
|
||||||
account={account}
|
account={account}
|
||||||
@ -421,8 +443,8 @@ export function MailAccountCard({
|
|||||||
plan={plan}
|
plan={plan}
|
||||||
disconnecting={disconnecting}
|
disconnecting={disconnecting}
|
||||||
onClose={() => setSettingsVisible(false)}
|
onClose={() => setSettingsVisible(false)}
|
||||||
onEditTitle={() => { setSettingsVisible(false); setEditTitleVisible(true); }}
|
onTitleSaved={handleTitleSaved}
|
||||||
onEditPassword={() => { setSettingsVisible(false); setEditPasswordVisible(true); }}
|
onPasswordSaved={onEditSuccess}
|
||||||
onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }}
|
onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }}
|
||||||
onIntervalChanged={onIntervalChanged}
|
onIntervalChanged={onIntervalChanged}
|
||||||
/>
|
/>
|
||||||
@ -448,23 +470,6 @@ export function MailAccountCard({
|
|||||||
onClose={() => setOauthDisconnectHintVisible(false)}
|
onClose={() => setOauthDisconnectHintVisible(false)}
|
||||||
t={t}
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
import { useMailInterval } from '../../hooks/useMailInterval';
|
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';
|
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||||
|
|
||||||
|
type EditMode = 'list' | 'edit-title' | 'edit-email' | 'edit-password';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
account: MailAccount;
|
account: MailAccount;
|
||||||
@ -13,8 +25,8 @@ type Props = {
|
|||||||
plan: 'free' | 'pro' | 'legend';
|
plan: 'free' | 'pro' | 'legend';
|
||||||
disconnecting?: boolean;
|
disconnecting?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEditTitle: () => void;
|
onTitleSaved: (newTitle: string | null) => void;
|
||||||
onEditPassword: () => void;
|
onPasswordSaved: () => void;
|
||||||
onDisconnectRequest: () => void;
|
onDisconnectRequest: () => void;
|
||||||
onIntervalChanged: () => void;
|
onIntervalChanged: () => void;
|
||||||
};
|
};
|
||||||
@ -30,13 +42,11 @@ function domainFromEmail(email: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsRow({
|
function SettingsRow({
|
||||||
icon,
|
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onPress,
|
onPress,
|
||||||
destructive,
|
destructive,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@ -58,12 +68,6 @@ function SettingsRow({
|
|||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: '#f5f5f5',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={16}
|
|
||||||
color={destructive ? '#dc2626' : '#737373'}
|
|
||||||
style={{ marginRight: 12, width: 20 }}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: labelColor }}
|
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({
|
export function MailAccountSettingsSheet({
|
||||||
visible,
|
visible,
|
||||||
account,
|
account,
|
||||||
@ -98,177 +214,352 @@ export function MailAccountSettingsSheet({
|
|||||||
plan,
|
plan,
|
||||||
disconnecting,
|
disconnecting,
|
||||||
onClose,
|
onClose,
|
||||||
onEditTitle,
|
onTitleSaved,
|
||||||
onEditPassword,
|
onPasswordSaved,
|
||||||
onDisconnectRequest,
|
onDisconnectRequest,
|
||||||
onIntervalChanged,
|
onIntervalChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setInterval, updating } = useMailInterval();
|
const { setInterval, updating } = useMailInterval();
|
||||||
|
const { saveTitle, saving: savingTitle, error: titleError } = useMailTitleEdit();
|
||||||
|
const { connect, connecting: connectingPassword, error: connectError } = useMailConnect();
|
||||||
|
|
||||||
const isLegend = plan === 'legend';
|
const isLegend = plan === 'legend';
|
||||||
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
||||||
|
|
||||||
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
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) {
|
async function handleSetInterval(value: number) {
|
||||||
const res = await setInterval(account.id, value);
|
const res = await setInterval(account.id, value);
|
||||||
if (res.ok) onIntervalChanged();
|
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 (
|
return (
|
||||||
<FormSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={handleClose}
|
||||||
title={displayTitle}
|
title={sheetTitle}
|
||||||
initialHeightPct={0.55}
|
initialHeightPct={mode === 'list' ? 0.55 : 0.5}
|
||||||
growWithKeyboard={false}
|
growWithKeyboard={mode !== 'list'}
|
||||||
>
|
>
|
||||||
<View style={{ paddingTop: 8 }}>
|
{mode === 'list' && (
|
||||||
{/* Bezeichnung */}
|
<View style={{ paddingTop: 8 }}>
|
||||||
<SettingsRow
|
{/* Bezeichnung */}
|
||||||
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 && (
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
icon="key-outline"
|
label={t('mail.row_title')}
|
||||||
label={t('mail.row_password')}
|
value={localTitle ?? '—'}
|
||||||
value="••••••••"
|
onPress={() => { setTitleDraft(localTitle ?? ''); setMode('edit-title'); }}
|
||||||
onPress={onEditPassword}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scan-Intervall */}
|
{/* E-Mail */}
|
||||||
{!isLegend ? (
|
<SettingsRow
|
||||||
<View
|
label={t('mail.row_email')}
|
||||||
style={{
|
value={account.email}
|
||||||
paddingHorizontal: 16,
|
onPress={!isOAuth ? () => setMode('edit-email') : undefined}
|
||||||
paddingVertical: 14,
|
/>
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#f5f5f5',
|
{/* Passwort — nur IMAP */}
|
||||||
}}
|
{!isOAuth && (
|
||||||
>
|
<SettingsRow
|
||||||
<Text
|
label={t('mail.row_password')}
|
||||||
|
value="••••••••"
|
||||||
|
onPress={() => { setPasswordDraft(''); setPasswordVisible(false); setMode('edit-password'); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan-Intervall */}
|
||||||
|
{!isLegend ? (
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
paddingHorizontal: 16,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
paddingVertical: 14,
|
||||||
color: '#737373',
|
borderBottomWidth: 1,
|
||||||
textTransform: 'uppercase',
|
borderBottomColor: '#f5f5f5',
|
||||||
letterSpacing: 0.6,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#a3a3a3',
|
color: '#737373',
|
||||||
marginTop: 6,
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('mail.free_scan_interval_hint')}
|
{t('mail.scan_interval_label')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||||
</View>
|
{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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#f0fdf4',
|
backgroundColor: '#f5f5f5',
|
||||||
marginHorizontal: 16,
|
borderRadius: 12,
|
||||||
marginVertical: 10,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 10,
|
marginBottom: (localError ?? connectError) ? 6 : 16,
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="flash" size={14} color="#16a34a" style={{ marginRight: 8 }} />
|
<TextInput
|
||||||
<Text
|
autoFocus
|
||||||
style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}
|
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')}
|
<Ionicons
|
||||||
</Text>
|
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={18}
|
||||||
|
color="#737373"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
{(localError ?? connectError) && (
|
||||||
<View style={{ height: 20 }} />
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#dc2626',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{localError ?? connectError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Verbindung trennen */}
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
activeOpacity={0.85}
|
||||||
activeOpacity={0.7}
|
onPress={handleSavePassword}
|
||||||
onPress={onDisconnectRequest}
|
disabled={connectingPassword}
|
||||||
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')}
|
<View
|
||||||
</Text>
|
style={{
|
||||||
</TouchableOpacity>
|
paddingVertical: 14,
|
||||||
</View>
|
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>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,21 +54,12 @@ function providerDisplayName(provider: string): string {
|
|||||||
export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
|
export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const [activeProvider, setActiveProvider] = useState('all');
|
|
||||||
|
|
||||||
const { results, total, loading, refresh } = useMailResults(expanded, activeProvider);
|
|
||||||
|
|
||||||
const filterOptions = ['all', ...providers];
|
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProviderFilter(p: string) {
|
|
||||||
setActiveProvider(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -130,97 +121,121 @@ export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||||
{/* Provider filter chips */}
|
<MailActivityLogBody providers={providers} colors={colors} />
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</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({
|
function ActivityItem({
|
||||||
item,
|
item,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { BlockedByDayEntry } from '../../hooks/useMailStats';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: BlockedByDayEntry[];
|
data: BlockedByDayEntry[];
|
||||||
|
granularity?: 'day' | 'week' | 'month';
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAR_AREA_HEIGHT = 64;
|
const BAR_AREA_HEIGHT = 64;
|
||||||
@ -16,7 +17,13 @@ function formatAxisLabel(dateStr: string): string {
|
|||||||
return `${d.getDate()}.${d.getMonth() + 1}.`;
|
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 { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
@ -58,7 +65,7 @@ export function MailBlockedByDayChart({ data }: Props) {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('mail.stats.blocked_per_day_heading')}
|
{t(headingKey(granularity))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{allZero ? (
|
{allZero ? (
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { HalfDonut } from '../common/HalfDonut';
|
||||||
import type { BlockedByConnectionEntry } from '../../hooks/useMailStats';
|
import type { BlockedByConnectionEntry } from '../../hooks/useMailStats';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: BlockedByConnectionEntry[];
|
data: BlockedByConnectionEntry[];
|
||||||
/** When true: renders as full-width hero card with integrated title row */
|
|
||||||
hero?: boolean;
|
hero?: boolean;
|
||||||
totalBlocked?: number;
|
totalBlocked?: number;
|
||||||
accountCount?: number;
|
accountCount?: number;
|
||||||
@ -17,17 +16,16 @@ type Props = {
|
|||||||
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
|
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
|
||||||
const OTHER_COLOR = '#a3a3a3';
|
const OTHER_COLOR = '#a3a3a3';
|
||||||
|
|
||||||
// Legend cap: show max 3 named entries + optional "others" row
|
|
||||||
const MAX_LEGEND_ENTRIES = 3;
|
const MAX_LEGEND_ENTRIES = 3;
|
||||||
|
|
||||||
const R_OUTER = 54;
|
const DONUT_WIDTH = 168;
|
||||||
const R_INNER = 34;
|
|
||||||
const CX = 64;
|
|
||||||
const CY = 64;
|
|
||||||
|
|
||||||
// Half-donut: upper semicircle, flat edge at bottom.
|
function formatCompact(n: number): string {
|
||||||
// Slices sweep from -90° (left) to +90° (right) = 180° total.
|
if (n < 1000) return n.toLocaleString();
|
||||||
const HALF_DONUT_START_DEG = -90;
|
const k = n / 1000;
|
||||||
|
if (k < 10) return `${Math.floor(k * 10) / 10}k+`;
|
||||||
|
return `${Math.floor(k)}k+`;
|
||||||
|
}
|
||||||
|
|
||||||
function domainFromEmail(email: string): string {
|
function domainFromEmail(email: string): string {
|
||||||
return email.split('@')[1] ?? email;
|
return email.split('@')[1] ?? email;
|
||||||
@ -37,45 +35,12 @@ function displayLabel(entry: BlockedByConnectionEntry): string {
|
|||||||
return entry.title ?? domainFromEmail(entry.email);
|
return entry.title ?? domainFromEmail(entry.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
function polarToXY(cx: number, cy: number, r: number, angleDeg: number) {
|
export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Props) {
|
||||||
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) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
const total = data.reduce((s, d) => s + d.count, 0);
|
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(() => {
|
const slices = useMemo(() => {
|
||||||
if (data.length === 0 || total === 0) return [];
|
if (data.length === 0 || total === 0) return [];
|
||||||
|
|
||||||
@ -87,11 +52,9 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
count: e.count,
|
count: e.count,
|
||||||
color: SLICE_COLORS[i] ?? OTHER_COLOR,
|
color: SLICE_COLORS[i] ?? OTHER_COLOR,
|
||||||
isOther: false,
|
isOther: false,
|
||||||
hiddenCount: 0,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4+ connections: Top-3 + Sonstige bucket
|
|
||||||
const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES);
|
const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES);
|
||||||
const rest = sorted.slice(MAX_LEGEND_ENTRIES);
|
const rest = sorted.slice(MAX_LEGEND_ENTRIES);
|
||||||
const restCount = rest.reduce((s, e) => s + e.count, 0);
|
const restCount = rest.reduce((s, e) => s + e.count, 0);
|
||||||
@ -102,7 +65,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
count: e.count,
|
count: e.count,
|
||||||
color: SLICE_COLORS[i],
|
color: SLICE_COLORS[i],
|
||||||
isOther: false,
|
isOther: false,
|
||||||
hiddenCount: 0,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@ -110,7 +72,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
count: restCount,
|
count: restCount,
|
||||||
color: OTHER_COLOR,
|
color: OTHER_COLOR,
|
||||||
isOther: true,
|
isOther: true,
|
||||||
hiddenCount: restConnectionCount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@ -118,9 +79,11 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
|
|
||||||
if (data.length <= 1 || total === 0) return null;
|
if (data.length <= 1 || total === 0) return null;
|
||||||
|
|
||||||
let cursor = HALF_DONUT_START_DEG;
|
|
||||||
|
|
||||||
const displayTotal = totalBlocked ?? total;
|
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) {
|
if (hero) {
|
||||||
return (
|
return (
|
||||||
@ -135,7 +98,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Integrated title row */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -143,20 +105,19 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<Text
|
||||||
<Text
|
style={{
|
||||||
style={{
|
flex: 1,
|
||||||
fontSize: 22,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: colors.error,
|
color: colors.textMuted,
|
||||||
lineHeight: 26,
|
textTransform: 'uppercase',
|
||||||
}}
|
letterSpacing: 0.7,
|
||||||
>
|
}}
|
||||||
{displayTotal.toLocaleString()}
|
>
|
||||||
</Text>
|
{t('mail.stats.distribution_heading')}
|
||||||
</View>
|
</Text>
|
||||||
|
|
||||||
{/* Live / Scheduled pill */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -188,24 +149,13 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Donut + Legend */}
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
<Svg width={128} height={68} viewBox="0 0 128 68">
|
<HalfDonut
|
||||||
{slices.map((slice) => {
|
segments={segments}
|
||||||
const sweep = (slice.count / total) * 180;
|
centerValue={centerValue}
|
||||||
const startDeg = cursor;
|
centerLabel={centerLabel}
|
||||||
cursor += sweep;
|
width={DONUT_WIDTH}
|
||||||
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>
|
|
||||||
|
|
||||||
<View style={{ flex: 1, gap: 5 }}>
|
<View style={{ flex: 1, gap: 5 }}>
|
||||||
{slices.map((slice) => (
|
{slices.map((slice) => (
|
||||||
<LegendRow key={slice.label} slice={slice} colors={colors} />
|
<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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -243,22 +192,12 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
<Svg width={128} height={68} viewBox="0 0 128 68">
|
<HalfDonut
|
||||||
{slices.map((slice) => {
|
segments={segments}
|
||||||
const sweep = (slice.count / total) * 180;
|
centerValue={centerValue}
|
||||||
const startDeg = cursor;
|
centerLabel={centerLabel}
|
||||||
cursor += sweep;
|
width={DONUT_WIDTH}
|
||||||
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>
|
|
||||||
|
|
||||||
<View style={{ flex: 1, gap: 6 }}>
|
<View style={{ flex: 1, gap: 6 }}>
|
||||||
{slices.map((slice) => (
|
{slices.map((slice) => (
|
||||||
<LegendRow key={slice.label} slice={slice} colors={colors} />
|
<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';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
export type BlockedByDayEntry = {
|
export type BlockedByDayEntry = {
|
||||||
@ -20,6 +20,106 @@ type MailStatsState = {
|
|||||||
loading: boolean;
|
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) {
|
export function useMailStats(enabled: boolean) {
|
||||||
const [state, setState] = useState<MailStatsState>({
|
const [state, setState] = useState<MailStatsState>({
|
||||||
blockedByDay: [],
|
blockedByDay: [],
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export type MailAccount = {
|
|||||||
lastConnectError?: string | null;
|
lastConnectError?: string | null;
|
||||||
lastConnectErrorAt?: string | null;
|
lastConnectErrorAt?: string | null;
|
||||||
lastIdleHeartbeatAt?: string | null;
|
lastIdleHeartbeatAt?: string | null;
|
||||||
|
/** ISO-date — present when backend includes it. Used for adaptive chart granularity. */
|
||||||
|
createdAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DailyStat = {
|
export type DailyStat = {
|
||||||
|
|||||||
@ -457,7 +457,10 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"blocked_per_day_heading": "Blockiert — letzte 30 Tage",
|
"blocked_per_day_heading": "Blockiert — letzte 30 Tage",
|
||||||
"blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche",
|
"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_heading": "Verteilung nach Postfach",
|
||||||
|
"distribution_center_label": "insgesamt",
|
||||||
"distribution_other": "Sonstige",
|
"distribution_other": "Sonstige",
|
||||||
"distribution_other_n": "+%{n} weitere",
|
"distribution_other_n": "+%{n} weitere",
|
||||||
"empty_title": "Noch keine Mails blockiert",
|
"empty_title": "Noch keine Mails blockiert",
|
||||||
@ -479,7 +482,10 @@
|
|||||||
"disconnect_hint_title": "Verbindung getrennt",
|
"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_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"
|
"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": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
|
|||||||
@ -457,7 +457,10 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"blocked_per_day_heading": "Blocked — last 30 days",
|
"blocked_per_day_heading": "Blocked — last 30 days",
|
||||||
"blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week",
|
"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_heading": "Distribution by mailbox",
|
||||||
|
"distribution_center_label": "total",
|
||||||
"distribution_other": "Others",
|
"distribution_other": "Others",
|
||||||
"distribution_other_n": "+%{n} more",
|
"distribution_other_n": "+%{n} more",
|
||||||
"empty_title": "No mails blocked yet",
|
"empty_title": "No mails blocked yet",
|
||||||
@ -479,7 +482,10 @@
|
|||||||
"disconnect_hint_title": "Connection removed",
|
"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_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"
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@ -480,7 +480,17 @@ async function runSession(conn) {
|
|||||||
clearConnectionError(conn.id).catch(() => {}),
|
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
|
// Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt
|
||||||
if (!conn.consentAt) {
|
if (!conn.consentAt) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user