import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, SquarePen, Trash2, X, Settings } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; import type { EmailSummary } from '../db/types'; import { useComposer, ComposeModal } from './Compose'; import { AccountsModal, useAccountsModal } from './AccountsModal'; import { emailPath, useSelectedEmailId } from './shared'; type EmailAccountRow = { id: number; provider: string; email: string; status: string; }; const LIMIT = 50; const FOLDERS = [ { key: 'inbox', label: 'Inbox', icon: Inbox }, { key: 'all', label: 'All', icon: Mail }, { key: 'sent', label: 'Sent', icon: Send }, { key: 'spam', label: 'Spam', icon: ShieldAlert }, { key: 'trash', label: 'Trash', icon: Trash2 }, ] as const; const formatDate = (iso: string) => { const date = new Date(iso); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const isThisYear = date.getFullYear() === now.getFullYear(); if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); }; export const EmailList = () => { const client = useClient(); const queryClient = useQueryClient(); const [, openCompose] = useComposer(); const [, openAccounts] = useAccountsModal(); const navigate = useNavigate(); const selectedId = useSelectedEmailId(); // Which folder you are reading is addressable state, so it is a query param rather than a global. // It stays a *param* and not a path segment because the path already spells which email is open: // `/email/:emailId` is the thing, the folder is the view you found it through, and switching folders // with a message open should not close it. const [searchParams, setSearchParams] = useSearchParams(); const folder = searchParams.get('folder') ?? 'inbox'; const qs = searchParams.toString() ? `?${searchParams}` : ''; const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const isSearching = debouncedSearch.length > 0; useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => clearTimeout(t); }, [search]); // Page 1 on a new search *or* a new folder. The folder half used to live in the click handler; it // cannot any more, because the folder pills are links and nobody handles their click. useEffect(() => { setPage(1); }, [debouncedSearch, folder]); const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({ queryKey: ['email-accounts'], queryFn: () => client.get('/email/accounts'), }); const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced'); const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued'); const hasAccounts = emailAccounts.length > 0; const { data, isLoading } = useQuery({ queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder], queryFn: () => isSearching ? client.get<{ messages: EmailSummary[]; total: number }>( `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`, ) : client.get<{ messages: EmailSummary[]; total: number }>( `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`, ), // Keep the previous results visible while the next query loads, so switching search terms/pages // never bails to the full-panel loading view (which would unmount the search input and drop focus). placeholderData: keepPreviousData, }); // Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching) const { data: allCount } = useQuery({ queryKey: ['email-messages-all-count'], queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'), enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0, }); useEffect(() => { if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) { // `replace`: this is the app correcting its own default, not a place you chose to be, so Back // should leave /email rather than bounce you between inbox and all. setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.set('folder', 'all'); return next; }, { replace: true }, ); } }, [isSearching, folder, data?.total, allCount, setSearchParams]); // Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch. // The EventSource closes automatically when this component unmounts (i.e. when you leave /email). useEffect(() => { const token = localStorage.getItem('BEARER_TOKEN'); if (!token) return; const es = new EventSource(`/api/email/events?token=${encodeURIComponent(token)}`); es.onmessage = (ev) => { try { if ((JSON.parse(ev.data) as { type?: string }).type === 'new-mail') { queryClient.invalidateQueries({ queryKey: ['email-messages'] }); queryClient.invalidateQueries({ queryKey: ['email-messages-all-count'] }); queryClient.invalidateQueries({ queryKey: ['email-accounts'] }); } } catch { /* ignore non-JSON keepalives */ } }; return () => es.close(); }, [queryClient]); const [syncing, setSyncing] = useState(false); const handleSync = async () => { if (!syncableAccount) return; setSyncing(true); try { const result = await client.post<{ ok: boolean; saved?: number }>( `/email/accounts/${syncableAccount.id}/sync`, {}, ); if (result.saved !== undefined) { toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails'); queryClient.invalidateQueries({ queryKey: ['email-messages'] }); } else { toast.success('Sync started'); } refetchAccounts(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to sync'); } finally { setSyncing(false); } }; // Poll accounts while first sync is running (job-based), refresh email list when done const prevSyncing = useRef(false); useEffect(() => { if (isSyncing) { const interval = setInterval(refetchAccounts, 5000); return () => clearInterval(interval); } if (prevSyncing.current && !isSyncing) { queryClient.invalidateQueries({ queryKey: ['email-messages'] }); } prevSyncing.current = isSyncing; }, [isSyncing, queryClient]); const messages = data?.messages ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / LIMIT)); // Arrow key navigation useEffect(() => { const handleKeyDown = (ev: KeyboardEvent) => { if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return; if (messages.length === 0) return; ev.preventDefault(); const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1; let nextId: string | undefined; if (ev.key === 'ArrowDown') { const next = currentIndex + 1; if (next < messages.length) nextId = messages[next]!.id; } else { const prev = currentIndex - 1; if (prev >= 0) nextId = messages[prev]!.id; } if (nextId) { // `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one // history entry per row, and Back would then walk the sweep instead of leaving the mailbox. // A click is a real link and does push. navigate(emailPath(nextId, qs), { replace: true }); document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [messages, selectedId, navigate, qs]); if (isLoading) { return
Loading emails...
; } // Show onboarding empty state only when no emails exist at all (never while searching) const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total; if (hasNoEmails && !isLoading && page === 1) { return (
{!hasAccounts ? (
Add an email account to get started
) : syncableAccount ? (
No emails synced yet
) : isSyncing ? (
Syncing emails...
) : ( No emails synced yet )}
); } return (
{FOLDERS.map((f) => { const Icon = f.icon; const isActive = folder === f.key; // Search-only `to`, so react-router keeps the current pathname — the open email survives a // folder switch. Built from the live params rather than written flat, so anything else in // the query string comes along. const next = new URLSearchParams(searchParams); next.set('folder', f.key); return ( {f.label} ); })}
{total} {syncing || isSyncing ? ( ) : syncableAccount ? ( ) : null} {totalPages > 1 && (
{page}/{totalPages}
)}
setSearch(ev.target.value)} placeholder="Search — try from:, subject:, has:attachment…" className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40" /> {isSearching && ( {total} result{total === 1 ? '' : 's'} )} {search && ( )}
{messages.length === 0 ? (
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
) : (
{messages.map((msg: EmailSummary) => { const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read; return (
{msg.from} {!!msg.threadCount && ( {msg.threadCount} )}
{formatDate(msg.date)}
{msg.subject}
{!!msg.attachmentCount && ( {msg.attachmentCount} )} {msg.snippet}
); })}
)}
); };