From 206941e5e167c67e5114c3720bc9ec3e21c61f04 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 13 May 2026 22:55:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(mail-page):=20UX=20polish=20=E2=80=94=20FAB?= =?UTF-8?q?-revert,=20legend=20cap,=20activity=20NaNd,=20instant=20heartbe?= =?UTF-8?q?at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Feedback nach Live-Test: Frontend: - FAB raus, Plus-Button zurück in den Account-Liste-Section-Header (`add-circle-outline` in brandOrange + Label "Postfach hinzufügen"). FAB stört am unteren Rand, oben passt zum iOS-NavBar-Pattern. - Half-Donut Legend strikt max Top-3 + "Sonstige" — Threshold von ≤4 auf ≤3 gesenkt. Auch bei 4 Connections wird jetzt schon komprimiert. - Hero-Donut-Subtitle "über N Postfächer" entfernt — Title-Block ist jetzt eine Zeile: "XX blockiert · ● Live" - Activity-Log default-collapsed war schon richtig (kein Change) - Activity-Item-Redesign: x-Icon-Pille raus, Zeit + Provider als Sub-Zeile unter dem Subject ("vor 2h · GMX"), kein Zeit-Label rechts mehr Bug-Fix — NaNd in Activity-Row: - Root-Cause: snake_case/camelCase-Mismatch. Backend liefert `receivedAt`, `senderEmail`, `senderName`, `connectionId` (camelCase), Frontend-Type hatte snake_case → undefined-Werte → `new Date(undefined)` → NaN → "NaNd"-Render - MailBlockedItem-Type auf camelCase umgestellt + nested `connection`-Objekt (passt jetzt zum Backend-Response) - formatDate mit Number.isFinite-Guard — gibt null zurück bei ungültigem Datum statt NaN-String zu rendern Backend (imap-idle daemon): - Daemon schreibt jetzt unmittelbar nach `client.connect()` einen Heartbeat (last_idle_heartbeat_at = NOW()) + clear last_connect_error parallel - Vorher: User sah 2-9min lang "wartet auf erste verbindung" obwohl Connection längst aktiv war (Heartbeat kam erst beim ersten NOOP-Cycle) - Re-Connect-Pfad nach AUTHENTICATIONFAILED ist automatisch mit abgedeckt (geht durch denselben connect-Block) - ESM-Daemon, kein Build-Step — Pipeline scp + pm2-restart reicht Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/(app)/mail.tsx | 108 +++++++++--------- .../components/mail/MailActivityLog.tsx | 83 +++----------- .../components/mail/MailDistributionChart.tsx | 19 +-- apps/rebreak-native/hooks/useMailResults.ts | 17 ++- backend/imap-idle/index.mjs | 13 ++- 5 files changed, 100 insertions(+), 140 deletions(-) diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 40d6558..9b67655 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -11,7 +11,6 @@ import { View, } from 'react-native'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { Ionicons } from '@expo/vector-icons'; import { AppHeader } from '../../components/AppHeader'; @@ -184,7 +183,6 @@ function MoreInfosSection({ export default function MailScreen() { const { t } = useTranslation(); const tabBarHeight = useBottomTabBarHeight(); - const insets = useSafeAreaInsets(); const colors = useColors(); const { plan } = useUserPlan(); @@ -265,7 +263,7 @@ export default function MailScreen() { contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, - paddingBottom: tabBarHeight + 88, + paddingBottom: tabBarHeight + 24, }} showsVerticalScrollIndicator={false} > @@ -365,32 +363,59 @@ export default function MailScreen() { {/* 2. ACCOUNT LIST */} {hasAccounts && ( - - {t('mail.section_accounts')} - - - {maxAccounts === Infinity - ? t('mail.section_accounts_count_unlimited', { used: accounts.length }) - : t('mail.section_accounts_count', { - used: accounts.length, - max: maxAccounts, - })} - + + + + {t('mail.section_accounts')} + + + {maxAccounts === Infinity + ? t('mail.section_accounts_count_unlimited', { used: accounts.length }) + : t('mail.section_accounts_count', { + used: accounts.length, + max: maxAccounts, + })} + + + + + + {t('mail.add_account')} + + + )} @@ -442,31 +467,6 @@ export default function MailScreen() { )} - {/* 5. FAB — Floating Action Button */} - - - - setSheetVisible(false)} diff --git a/apps/rebreak-native/components/mail/MailActivityLog.tsx b/apps/rebreak-native/components/mail/MailActivityLog.tsx index 4a152e3..4e1493c 100644 --- a/apps/rebreak-native/components/mail/MailActivityLog.tsx +++ b/apps/rebreak-native/components/mail/MailActivityLog.tsx @@ -23,14 +23,16 @@ type Props = { providers?: string[]; }; -function formatDate(iso: string, t: (k: string) => string): string { - const diff = Date.now() - new Date(iso).getTime(); +function formatDate(iso: string, t: (k: string) => string): string | null { + const ts = new Date(iso).getTime(); + if (!Number.isFinite(ts)) return null; + const diff = Date.now() - ts; const mins = Math.floor(diff / 60_000); if (mins < 2) return t('mail.account_just_now'); if (mins < 60) return `${mins} min`; const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; + if (hours < 24) return `vor ${hours}h`; + return `vor ${Math.floor(hours / 24)}d`; } function domainFromEmail(email: string): string { @@ -228,87 +230,40 @@ function ActivityItem({ t: (k: string, opts?: any) => string; colors: ReturnType; }) { - const accountLabel = item.connection_title ?? ( - item.sender_email ? domainFromEmail(item.sender_email) : null + const providerLabel = item.connection?.providerLabel ?? ( + item.senderEmail ? domainFromEmail(item.senderEmail) : null ); + const timeLabel = formatDate(item.receivedAt, t); + const subLine = [timeLabel, providerLabel].filter(Boolean).join(' · '); return ( - - - - - - - {item.subject || t('mail.activity_no_subject')} - - {accountLabel && ( - - - {accountLabel} - - - )} - + {item.subject || t('mail.activity_no_subject')} + + {subLine.length > 0 && ( - {item.sender_name || item.sender_email} + {subLine} - - - {formatDate(item.received_at, t)} - + )} ); } diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx index 5b587e5..11939a7 100644 --- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -74,15 +74,14 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, const total = data.reduce((s, d) => s + d.count, 0); - // Build donut slices: always cap at 4 visible segments (Top-3 + Sonstige). - // Edge-case: ≤3 → no grouping. Exactly 4 → show all 4 (no grouping). - // 5+ → Top-3 + Sonstige. + // 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 []; const sorted = [...data].sort((a, b) => b.count - a.count); - if (sorted.length <= 4) { + if (sorted.length <= MAX_LEGEND_ENTRIES) { return sorted.map((e, i) => ({ label: displayLabel(e), count: e.count, @@ -92,7 +91,7 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, })); } - // 5+ connections: Top-3 + Sonstige bucket + // 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); @@ -155,16 +154,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, > {displayTotal.toLocaleString()} - - {t('mail.stats_account_summary', { count: accountCount ?? data.length })} - {/* Live / Scheduled pill */} diff --git a/apps/rebreak-native/hooks/useMailResults.ts b/apps/rebreak-native/hooks/useMailResults.ts index 8360338..c88766a 100644 --- a/apps/rebreak-native/hooks/useMailResults.ts +++ b/apps/rebreak-native/hooks/useMailResults.ts @@ -4,12 +4,17 @@ import { apiFetch } from "../lib/api"; export type MailBlockedItem = { id: string; subject: string; - sender_email: string; - sender_name: string | null; - received_at: string; - connection_id: string; - connection_title?: string | null; - provider?: string | null; + senderEmail: string; + senderName: string | null; + receivedAt: string; + connectionId: string; + connection?: { + id: string; + email: string; + title: string | null; + provider: string; + providerLabel: string; + } | null; }; export type MailResultsResponse = { diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 6058f78..043af0c 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -467,7 +467,18 @@ async function runSession(conn) { log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`); attempt = 0; // Reset nach erfolgreicher Verbindung authRetries = 0; // Auth-Retry-Counter ebenfalls reset - await clearConnectionError(conn.id).catch(() => {}); + // Initial-Heartbeat: sofort nach erfolgreichem Connect schreiben damit + // das Frontend "aktiv" zeigt statt bis zum ersten NOOP-Cycle zu warten + // (NOOP-Cycle = alle 2min → worst-case 2min+, gemessene delay 2-9min). + // Gilt für alle auth-Methoden (app_password + oauth2_microsoft) und + // auch für den Re-Connect nach AUTHENTICATIONFAILED-Recovery, da beide + // Paths durch diesen Block laufen. + // last_connect_error wird gleichzeitig geclearet: ein zuvor failed-State + // (z.B. abgelaufener Token) ist nach erfolgreichem Connect behoben. + await Promise.all([ + updateIdleHeartbeat(conn.id).catch(() => {}), + clearConnectionError(conn.id).catch(() => {}), + ]); await imap.getMailboxLock("INBOX");