Files
email/web/EmailList.tsx
T
pastilhasandClaude Opus 5 91b978c043 mount the accounts modal in the empty state, so a fresh install can add its first account
Clicking "Add Account" on a new install did nothing at all — no modal, no
error, nothing in the console.

EmailList early-returns an onboarding empty state when there is no mail, and
that branch carries the "Add Account" button. But <AccountsModal /> was only
mounted in the OTHER return — the one that renders when mail exists. The button
flipped the shared EMAIL_ACCOUNTS_OPEN global and nothing was listening.

A catch-22, and it closed the only door in: the sole other trigger is in the
toolbar, which is also in the mail-exists branch. So an install with no account
could never reach the dialog that adds one, and the failure was silent in both
directions — nothing threw, and the state flag really did flip.

Mount it in both branches. The empty state is exactly when it is needed most.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:50:46 +00:00

423 lines
17 KiB
TypeScript

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<EmailAccountRow[]>('/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 <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
}
// 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 (
<>
{/*
The modal has to be mounted HERE too, not only in the main return below.
It was only down there, and the result was a catch-22 you could not get out of: with no account
the component takes this early return, "Add Account" flips the shared `EMAIL_ACCOUNTS_OPEN`
global — and nothing is listening, because `<AccountsModal />` lives in the branch that renders
when mail EXISTS. Clicking it did nothing at all, with no error anywhere. The only other trigger
is in the toolbar, which is also in that branch, so a fresh install had no reachable way to add
the first account.
*/}
<AccountsModal />
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
<Mail className="h-8 w-8" />
{!hasAccounts ? (
<div className="flex flex-col items-center gap-2">
<span>Add an email account to get started</span>
<Button variant="outline" size="sm" onClick={() => openAccounts(true)}>
Add Account
</Button>
</div>
) : syncableAccount ? (
<div className="flex flex-col items-center gap-2">
<span>No emails synced yet</span>
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}>
{syncing || isSyncing ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Sync Now
</Button>
</div>
) : isSyncing ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Syncing emails...</span>
</div>
) : (
<span>No emails synced yet</span>
)}
</div>
</>
);
}
return (
<div className="flex h-full flex-col">
<ComposeModal />
<AccountsModal />
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div className="flex items-center gap-1">
{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 (
<Link
key={f.key}
to={{ search: `?${next}` }}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
}`}
title={f.label}
aria-current={isActive ? 'true' : undefined}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{f.label}</span>
</Link>
);
})}
</div>
<span className="text-xs opacity-50">{total}</span>
{syncing || isSyncing ? (
<span title="Syncing..." className="flex shrink-0">
<Loader2 className="h-3.5 w-3.5 animate-spin opacity-50" />
</span>
) : syncableAccount ? (
<button
onClick={handleSync}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Sync emails"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : null}
<button
onClick={() => openAccounts(true)}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Email accounts"
>
<Settings className="h-3.5 w-3.5" />
</button>
<button
onClick={() => openCompose({})}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Compose"
>
<SquarePen className="h-3.5 w-3.5" />
</button>
{totalPages > 1 && (
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs opacity-60">
{page}/{totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
<Search className="h-3.5 w-3.5 shrink-0 opacity-40" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search — try from:, subject:, has:attachment…"
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
/>
{isSearching && (
<span className="text-xs opacity-50 shrink-0">
{total} result{total === 1 ? '' : 's'}
</span>
)}
{search && (
<button
onClick={() => setSearch('')}
className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer"
title="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-sm opacity-40">
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
</div>
) : (
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
{messages.map((msg: EmailSummary) => {
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
return (
<Link
key={msg.id}
to={emailPath(msg.id, qs)}
data-email-id={msg.id}
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>
{msg.from}
</span>
{!!msg.threadCount && (
<span
className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60"
title={`${msg.threadCount} messages`}
>
{msg.threadCount}
</span>
)}
</div>
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
</div>
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
<div className="flex items-center gap-1.5 text-xs">
{!!msg.attachmentCount && (
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
<Paperclip className="h-3 w-3" />
{msg.attachmentCount}
</span>
)}
<span className="truncate opacity-50">{msg.snippet}</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
};