import { useEffect, useCallback, useRef, useState } from 'react'; import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js'; import BulkToolbar from '../components/BulkToolbar.jsx'; import useSelection from '../hooks/useSelection.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; // ── helpers ────────────────────────────────────────────────────────────────── const fmtDate = (iso) => { const d = new Date(iso); const now = new Date(); const diffDays = (now - d) / 86400000; if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); }; const fmtSize = (b) => { if (!b) return ''; if (b < 1024) return `${b} B`; if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`; return `${(b / 1048576).toFixed(1)} MB`; }; const PAGE_SIZE = 50; // ── SenderList (left panel) ─────────────────────────────────────────────────── function SenderList({ senders, selectedId, onSelect, search, onSearch }) { const filtered = senders.filter((s) => { const q = search.toLowerCase(); return !q || (s.address + ' ' + (s.displayName ?? '')).toLowerCase().includes(q); }); return (
onSearch(e.target.value)} aria-label="Filter senders" />
{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}
); } // ── EmailDetail (full single-email view) ────────────────────────────────────── function EmailDetail({ email: summary, onBack }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [aiSummary, setAiSummary] = useState(null); const [aiLoading, setAiLoading] = useState(false); useEffect(() => { setDetail(null); setLoading(true); setAiSummary(null); EmailApi.get(summary.id) .then(setDetail) .catch(() => setDetail(null)) .finally(() => setLoading(false)); }, [summary.id]); const email = detail ?? summary; const fetchAiSummary = () => { setAiLoading(true); EmailApi.summary(summary.id) .then((r) => setAiSummary(r.summary)) .catch(() => setAiSummary(null)) .finally(() => setAiLoading(false)); }; return (
{email.subject || '(no subject)'}
{email.senderDisplayName || email.senderAddress} · {new Date(email.sentAtUtc).toLocaleString()} {email.sizeEstimateBytes > 0 && ( <> · {fmtSize(email.sizeEstimateBytes)} )}
{!loading && (
{aiSummary != null ? (
✨ {aiSummary}
) : ( )}
)} {loading &&
Loading message…
} {!loading && detail?.bodyText && (
{detail.bodyText}
)} {!loading && !detail?.bodyText && email.snippet && (
{email.snippet}
)}
); } // ── EmailListRow (row inside sender email list) ─────────────────────────────── function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect, focused }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); const act = (fn, patch) => async (e) => { e.stopPropagation(); if (acting) return; setActing(true); try { await fn(email.id); setEmail((p) => ({ ...p, ...patch })); } finally { setActing(false); } }; const handleTrash = async (e) => { e.stopPropagation(); if (acting) return; setActing(true); try { await EmailApi.trash(email.id); onRemove?.(email.id); } finally { setActing(false); } }; return ( onOpen(email)} title="View email" style={{ cursor: 'pointer' }} > e.stopPropagation()}> onToggleSelect(email.id)} /> {email.isUnread && } {email.subject || '(no subject)'} {email.snippet && — {email.snippet}} {email.hasAttachments && 📎} {email.sizeEstimateBytes > 1048576 && {fmtSize(email.sizeEstimateBytes)}} {fmtDate(email.sentAtUtc)} e.stopPropagation()}> ); } // ── SenderEmails (right panel) ──────────────────────────────────────────────── function SenderEmails({ sender }) { const [emails, setEmails] = useState([]); const [totalCount, setTotalCount] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [openEmail, setOpenEmail] = useState(null); const sentinelRef = useRef(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); // Reset when sender changes useEffect(() => { setEmails([]); setTotalCount(null); setPage(1); setHasMore(true); setOpenEmail(null); clear(); }, [sender.senderId, clear]); const fetchPage = useCallback((p) => { setLoading(true); SearchApi.query(`from:${sender.address}`, p, PAGE_SIZE) .then((r) => { setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]); setTotalCount(r.totalCount); setHasMore(p < r.totalPages); setPage(p); }) .finally(() => setLoading(false)); }, [sender]); useEffect(() => { fetchPage(1); }, [fetchPage]); // Infinite scroll sentinel useEffect(() => { const el = sentinelRef.current; if (!el) return; const obs = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1); }, { rootMargin: '200px' } ); obs.observe(el); return () => obs.disconnect(); }, [hasMore, loading, page, fetchPage]); if (openEmail) { return setOpenEmail(null)} />; } return (
{sender.displayName || sender.address}
{sender.displayName ? sender.address : ''}
{totalCount !== null && (
{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}
)}
{ if (action === 'trash' || action === 'archive') { setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); removeIds(ids); } }} /> {emails.length > 0 && ( {emails.map((e) => ( setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))}
)}
{loading &&
Loading…
} {!loading && !hasMore && emails.length > 0 && (
— {emails.length.toLocaleString()} emails —
)} {!loading && emails.length === 0 && (
No emails found from this sender.
)}
); } // ── Page ────────────────────────────────────────────────────────────────────── export default function Senders() { const [senders, setSenders] = useState(null); const [selected, setSelected] = useState(null); const [search, setSearch] = useState(''); useEffect(() => { AnalyticsApi.allSenders().then((list) => { setSenders(list); if (list.length > 0) setSelected(list[0]); }).catch(() => setSenders([])); }, []); if (!senders) return

Senders

Loading…

; if (senders.length === 0) return (

Senders

No senders yet — run a sync first.

); return (
{selected && }
); }