import { useState } from 'react'; import { ActivityIndicator, LayoutAnimation, Platform, Pressable, TouchableOpacity, Text, UIManager, View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { ConfirmAlert } from '../ConfirmAlert'; import { EditMailAccountSheet } from './EditMailAccountSheet'; import { useMailInterval } from '../../hooks/useMailInterval'; import type { MailAccount } from '../../hooks/useMailStatus'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); } type Props = { account: MailAccount; plan: 'free' | 'pro' | 'legend'; expanded: boolean; onToggle: () => void; onDisconnect: (id: string) => Promise; onIntervalChanged: () => void; onEditSuccess: () => void; disconnecting?: boolean; }; function PausedBadge({ t }: { t: (k: string) => string }) { return ( {t('plan_limit.mail_account_paused')} ); } function resolveProviderIcon(provider: string): { icon: React.ComponentProps['name']; color: string; } { const p = provider.toLowerCase(); if (p.includes('gmail') || p.includes('google')) return { icon: 'mail', color: '#EA4335' }; if (p.includes('icloud') || p.includes('apple')) return { icon: 'cloud', color: '#007AFF' }; if (p.includes('outlook') || p.includes('hotmail') || p.includes('microsoft')) return { icon: 'mail-open', color: '#0078D4' }; if (p.includes('yahoo')) return { icon: 'at', color: '#7C3AED' }; if (p.includes('gmx') || p.includes('web.de')) return { icon: 'mail-unread', color: '#E87A22' }; return { icon: 'server', color: '#737373' }; } const STALE_THRESHOLD_MS = 5 * 60 * 1_000; const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS; const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000; function formatRelativeAbsolute(ts: Date): string { const min = Math.floor((Date.now() - ts.getTime()) / 60_000); const todayStr = new Date().toDateString(); const yesterdayStr = new Date(Date.now() - 86_400_000).toDateString(); const hh = ts.getHours().toString().padStart(2, '0'); const mm = ts.getMinutes().toString().padStart(2, '0'); let dayLabel: string; if (ts.toDateString() === todayStr) dayLabel = 'heute'; else if (ts.toDateString() === yesterdayStr) dayLabel = 'gestern'; else dayLabel = ts.toLocaleDateString('de', { day: '2-digit', month: '2-digit' }); let rel: string; if (min < 1) rel = 'gerade eben'; else if (min < 60) rel = `vor ${min} min`; else if (min < 1440) rel = `vor ${Math.floor(min / 60)}h`; else rel = `vor ${Math.floor(min / 1440)}d`; return `${rel} (${dayLabel} ${hh}:${mm})`; } function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean { if (!lastIdleHeartbeatAt) return false; return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS; } function StatusBadgeRow({ account, isLegend, t, }: { account: MailAccount; isLegend: boolean; t: (k: string, opts?: Record) => string; }) { // Priority 1 — auth / connect error if (account.lastConnectError) { const isAuthError = account.lastConnectError.toLowerCase().includes('invalid credentials') || account.lastConnectError.toLowerCase().includes('authentication failed'); const errorLabel = isAuthError ? t('mail.status_auth_error') : t('mail.status_connect_error'); const since = account.lastConnectErrorAt ? formatRelativeAbsolute(new Date(account.lastConnectErrorAt)) : null; return ( {errorLabel} · {t('mail.status_error_tap_hint')} {since ? ( {since} ) : null} ); } // Priority 5 — never connected if (!account.lastScannedAt) { return ( {t('mail.status_waiting_first_connect')} ); } const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt); const lastScannedTs = new Date(account.lastScannedAt); const scannedAgo = Date.now() - lastScannedTs.getTime(); const scannedRelAbs = formatRelativeAbsolute(lastScannedTs); // Priority 4 — stale: heartbeat missing/expired AND scan is old if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) { return ( {t('mail.status_stale')} {t('mail.status_stale_last_scan', { rel: scannedRelAbs })} ); } // Priority 2 + 3 — heartbeat alive (or scan recent enough for pre-migration backend) if (heartbeatAlive) { const heartbeatTs = new Date(account.lastIdleHeartbeatAt!); const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000); const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`; if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) { // Priority 3 — connected but no new mail for >1h return ( {isLegend ? t('mail.live') : t('mail.account_active')} {t('mail.status_live_no_new_mail', { rel: scannedRelAbs })} ); } // Priority 2 — live + heartbeat recent + scan recent return ( {isLegend ? t('mail.live') : t('mail.account_active')} {t('mail.status_live_idle', { rel: idleSince })} ); } // Fallback — scan recent, backend without heartbeat field return ( {isLegend ? t('mail.live') : t('mail.account_active')} {scannedRelAbs} ); } const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { free: [4], pro: [1, 4, 8], legend: [1, 4, 8], }; const HEADER_ROW = { flexDirection: 'row' as const, alignItems: 'center' as const, paddingHorizontal: 14, paddingVertical: 14, }; const ACTION_BTN_BASE = { flex: 1, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'center' as const, paddingVertical: 12, borderRadius: 10, }; export function MailAccountCard({ account, plan, expanded, onToggle, onDisconnect, onIntervalChanged, onEditSuccess, disconnecting, }: Props) { const { t } = useTranslation(); const [confirmVisible, setConfirmVisible] = useState(false); const [editVisible, setEditVisible] = useState(false); const { setInterval, updating } = useMailInterval(); const { icon, color } = resolveProviderIcon(account.provider); const isLegend = plan === 'legend'; const isPaused = account.paused === true; const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; function handleToggle() { if (account.lastConnectError) { setEditVisible(true); return; } LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } async function handleSetInterval(value: number) { const res = await setInterval(account.id, value); if (res.ok) onIntervalChanged(); } return ( <> {/* Header */} {account.email} {isPaused ? : } {/* Body */} {expanded && ( {t('mail.account_stat_blocked')} {account.totalBlocked.toLocaleString()} {t('mail.account_of_scanned', { scanned: account.totalScanned.toLocaleString(), })} {isLegend ? ( {t('mail.realtime_desc')} ) : ( {t('mail.scan_interval_label')} {intervalOptions.map((opt, idx) => { const active = account.scanInterval === opt; const disabled = plan === 'free' || updating === account.id; return ( handleSetInterval(opt)} style={{ flex: 1, paddingVertical: 9, borderRadius: 10, alignItems: 'center', backgroundColor: active ? '#007AFF' : '#f5f5f5', marginLeft: idx === 0 ? 0 : 6, opacity: disabled && !active ? 0.5 : 1, }} > {opt}h ); })} {plan === 'free' && ( {t('mail.free_scan_interval_hint')} )} )} setEditVisible(true)} style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }} > {t('mail.account_change_password')} setConfirmVisible(true)} disabled={disconnecting} style={{ ...ACTION_BTN_BASE, backgroundColor: '#fef2f2', marginLeft: 6, opacity: disconnecting ? 0.6 : 1, }} > {disconnecting ? ( ) : ( <> {t('mail.disconnect')} )} )} { setConfirmVisible(false); await onDisconnect(account.id); }} onCancel={() => setConfirmVisible(false)} /> setEditVisible(false)} onSuccess={onEditSuccess} /> ); }