"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { CheckCircle2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComposeDialog, type ComposeDraft } from "./compose-dialog";
import { MAIL_LABELS, ME, SEED_NOW, SEED_THREADS } from "./data";
import { formatRecipients, lastDate, parseRecipients } from "./mail-ui";
import { FOLDERS, MailSidebar, type View } from "./mail-sidebar";
import { MessageList } from "./message-list";
import { ReadingPane } from "./reading-pane";
import type { FolderView, MailFolder, MailLabel, MailThread, OutgoingMail, Person } from "./types";
export type { MailThread, MailLabel, Person, OutgoingMail };
export interface MailAppProps {
/** Threads to show. Defaults to ~20 seeded demo conversations. */
initialThreads?: MailThread[];
labels?: MailLabel[];
/** The signed-in account (used as the sender). */
account?: Person;
/** Reference "now" for relative dates. Defaults to the seed timestamp (demo) or the client clock (your data). */
now?: Date | string;
/** Called when a message or reply is sent — hand it to your mail API. */
onSend?: (mail: OutgoingMail) => void;
/** Called with all threads after every change. */
onChange?: (threads: MailThread[]) => void;
className?: string;
}
type Toast = { id: number; text: string; undo?: () => void };
let seq = 0;
const newId = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`;
export function MailApp({ initialThreads, labels = MAIL_LABELS, account = ME, now: nowProp, onSend, onChange, className }: MailAppProps) {
const [threads, setThreads] = React.useState<MailThread[]>(initialThreads ?? SEED_THREADS);
const [view, setView] = React.useState<View>({ kind: "folder", id: "inbox" });
const [query, setQuery] = React.useState("");
const [unreadOnly, setUnreadOnly] = React.useState(false);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [activeId, setActiveId] = React.useState<string | null>(null);
const [compose, setCompose] = React.useState<ComposeDraft | null>(null);
const [drawer, setDrawer] = React.useState(false);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const [now, setNow] = React.useState(() => new Date(nowProp ?? SEED_NOW));
const mountedAt = React.useRef(0);
const searchRef = React.useRef<HTMLInputElement | null>(null);
const registerSearch = React.useCallback((el: HTMLInputElement | null) => {
searchRef.current = el;
}, []);
// Clock: with the demo seed we stay anchored to SEED_NOW; with real data we use the client clock.
React.useEffect(() => {
mountedAt.current = Date.now();
if (!nowProp && initialThreads) setNow(new Date());
}, [nowProp, initialThreads]);
const stamp = React.useCallback(() => new Date(now.getTime() + (Date.now() - (mountedAt.current || Date.now()))).toISOString(), [now]);
// Open the newest conversation on wide screens so the reading pane isn't empty.
React.useEffect(() => {
if (window.matchMedia("(min-width: 768px)").matches) {
const first = [...threads].filter((t) => t.folder === "inbox").sort((a, b) => lastDate(b).localeCompare(lastDate(a)))[0];
if (first) {
setActiveId(first.id);
setThreads((ts) => ts.map((t) => (t.id === first.id ? { ...t, unread: false } : t)));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
}, []);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const firstRender = React.useRef(true);
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
onChangeRef.current?.(threads);
}, [threads]);
const labelsById = React.useMemo(() => Object.fromEntries(labels.map((l) => [l.id, l])), [labels]);
/* ------------------------------ derived ------------------------------ */
const inView = React.useCallback(
(t: MailThread) => {
if (view.kind === "label") return t.labels.includes(view.id) && t.folder !== "trash";
if (view.id === "starred") return t.starred && t.folder !== "trash";
return t.folder === view.id;
},
[view],
);
const q = query.trim().toLowerCase();
const visible = React.useMemo(
() =>
threads
.filter(inView)
.filter((t) => !unreadOnly || t.unread)
.filter(
(t) =>
!q ||
t.subject.toLowerCase().includes(q) ||
t.messages.some((m) => m.body.toLowerCase().includes(q) || m.from.name.toLowerCase().includes(q) || m.from.email.toLowerCase().includes(q)),
)
.sort((a, b) => lastDate(b).localeCompare(lastDate(a))),
[threads, inView, unreadOnly, q],
);
// Selection only ever refers to rows that are on screen.
const visibleSelected = React.useMemo(() => new Set([...selected].filter((id) => visible.some((t) => t.id === id))), [selected, visible]);
const counts = React.useMemo(() => {
const c = { inbox: 0, starred: 0, sent: 0, drafts: 0, archive: 0, trash: 0 } as Record<FolderView, number>;
for (const t of threads) {
if (t.folder === "inbox" && t.unread) c.inbox += 1;
if (t.starred && t.folder !== "trash") c.starred += 1;
if (t.folder === "drafts") c.drafts += 1;
if (t.folder === "trash") c.trash += 1;
}
return c;
}, [threads]);
const labelCounts = React.useMemo(() => {
const c: Record<string, number> = {};
for (const t of threads) if (t.unread && t.folder !== "trash") for (const l of t.labels) c[l] = (c[l] ?? 0) + 1;
return c;
}, [threads]);
const active = threads.find((t) => t.id === activeId) ?? null;
const activeIndex = visible.findIndex((t) => t.id === activeId);
const mode = view.kind === "folder" && (view.id === "trash" || view.id === "archive" || view.id === "drafts") ? view.id : "default";
const title = view.kind === "label" ? labelsById[view.id]?.name ?? "Label" : FOLDERS.find((f) => f.id === view.id)?.name ?? "Mail";
/* ------------------------------ toasts ------------------------------ */
const toast = React.useCallback((text: string, undo?: () => void) => {
const id = ++seq;
setToasts((ts) => [...ts.slice(-2), { id, text, undo }]);
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 5000);
}, []);
const dismiss = (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id));
/* ------------------------------ actions ------------------------------ */
const patch = (ids: string[], fn: (t: MailThread) => MailThread | null) =>
setThreads((ts) => ts.flatMap((t) => (ids.includes(t.id) ? (fn(t) ?? []) : [t])));
/** After removing the active thread from view, move to its neighbour (like a triage inbox). */
const advanceFrom = (ids: string[]) => {
if (!activeId || !ids.includes(activeId)) return;
const rest = visible.filter((t) => !ids.includes(t.id));
const next = visible.slice(activeIndex + 1).find((t) => !ids.includes(t.id)) ?? rest[rest.length - 1];
const wide = typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches;
setActiveId(wide && next ? next.id : null);
if (wide && next) patch([next.id], (t) => ({ ...t, unread: false }));
};
const withUndo = (text: string, change: () => void) => {
const snapshot = threads;
const snapActive = activeId;
change();
toast(text, () => {
setThreads(snapshot);
setActiveId(snapActive);
});
};
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
const archive = (ids: string[]) =>
withUndo(`Archived ${plural(ids.length, "conversation")}`, () => {
advanceFrom(ids);
patch(ids, (t) => ({ ...t, folder: "archive" }));
setSelected(new Set());
});
const remove = (ids: string[]) => {
const permanent = mode === "trash";
withUndo(permanent ? `Deleted ${plural(ids.length, "conversation")} forever` : `Moved ${plural(ids.length, "conversation")} to Trash`, () => {
advanceFrom(ids);
patch(ids, (t) => (permanent ? null : { ...t, folder: "trash", trashedFrom: t.folder }));
setSelected(new Set());
});
};
const restore = (ids: string[]) =>
withUndo(mode === "archive" ? `Moved ${plural(ids.length, "conversation")} to Inbox` : `Restored ${plural(ids.length, "conversation")}`, () => {
advanceFrom(ids);
patch(ids, (t) => ({ ...t, folder: t.folder === "trash" ? (t.trashedFrom ?? "inbox") : "inbox", trashedFrom: undefined }));
setSelected(new Set());
});
const markRead = (ids: string[], read: boolean) => {
patch(ids, (t) => ({ ...t, unread: !read }));
setSelected(new Set());
};
const toggleStar = (id: string) => patch([id], (t) => ({ ...t, starred: !t.starred }));
const openThread = (id: string) => {
const t = threads.find((x) => x.id === id);
if (!t) return;
if (t.folder === "drafts") {
const m = t.messages[t.messages.length - 1];
setCompose({ draftId: t.id, to: formatRecipients(m?.to ?? []), subject: t.subject, body: m?.body ?? "" });
return;
}
setActiveId(id);
if (t.unread) patch([id], (x) => ({ ...x, unread: false }));
};
const step = (dir: 1 | -1) => {
const i = activeIndex === -1 ? (dir === 1 ? 0 : visible.length - 1) : activeIndex + dir;
const t = visible[i];
if (t) {
openThread(t.id);
requestAnimationFrame(() => document.querySelector(`[data-thread-row="${t.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
};
const reply = (body: string) => {
if (!active) return;
const to = [...active.messages].reverse().find((m) => m.from.email !== account.email)?.from ?? active.messages[0].to[0];
const msg = { id: newId("msg"), from: account, to: [to], date: stamp(), body };
patch([active.id], (t) => ({ ...t, messages: [...t.messages, msg] }));
onSend?.({ to: [to], subject: active.subject.startsWith("Re:") ? active.subject : `Re: ${active.subject}`, body, inReplyTo: active.id });
toast(`Reply sent to ${to.name.split(" ")[0]}`);
};
const hasContent = (d: ComposeDraft) => Boolean(d.to.trim() || d.subject.trim() || d.body.trim());
const toThread = (d: ComposeDraft, folder: MailFolder, id: string): MailThread => ({
id,
subject: d.subject.trim(),
folder,
starred: false,
unread: false,
labels: [],
messages: [{ id: newId("msg"), from: account, to: parseRecipients(d.to), date: stamp(), body: d.body }],
});
const sendCompose = (d: ComposeDraft) => {
const id = d.draftId ?? newId("th");
const thread = toThread(d, "sent", id);
const snapshot = threads;
setThreads((ts) => [thread, ...ts.filter((t) => t.id !== id)]);
setCompose(null);
onSend?.({ to: thread.messages[0].to, subject: thread.subject, body: d.body });
toast("Message sent", () => {
setThreads(snapshot);
setCompose(d);
});
};
const closeCompose = (d: ComposeDraft) => {
setCompose(null);
if (!hasContent(d)) {
if (d.draftId) setThreads((ts) => ts.filter((t) => t.id !== d.draftId));
return;
}
const id = d.draftId ?? newId("th");
const draft = toThread(d, "drafts", id);
setThreads((ts) => [draft, ...ts.filter((t) => t.id !== id)]);
toast("Draft saved");
};
const discardCompose = (d: ComposeDraft) => {
setCompose(null);
if (d.draftId || hasContent(d)) {
const snapshot = threads;
if (d.draftId) setThreads((ts) => ts.filter((t) => t.id !== d.draftId));
toast("Draft discarded", () => {
setThreads(snapshot);
setCompose(d);
});
}
};
const changeView = (v: View) => {
setView(v);
setSelected(new Set());
setDrawer(false);
setActiveId((id) => {
const t = threads.find((x) => x.id === id);
if (!t) return null;
const stillVisible = v.kind === "label" ? t.labels.includes(v.id) : v.id === "starred" ? t.starred : t.folder === v.id;
return stillVisible ? id : null;
});
};
/* ------------------------------ keyboard ------------------------------ */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
const t = e.target as HTMLElement;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "Escape") {
if (drawer) return setDrawer(false);
if (visibleSelected.size) return setSelected(new Set());
if (!window.matchMedia("(min-width: 768px)").matches && activeId) return setActiveId(null);
return;
}
if (compose || t.closest("input,textarea,select,[contenteditable]")) return;
const k = e.key;
const act = (fn: () => void) => {
e.preventDefault();
fn();
};
if (k === "c") act(() => setCompose({ to: "", subject: "", body: "" }));
else if (k === "/") act(() => searchRef.current?.focus());
else if (k === "j" || k === "ArrowDown") act(() => step(1));
else if (k === "k" || k === "ArrowUp") act(() => step(-1));
else if (active && k === "e" && mode !== "archive" && mode !== "trash") act(() => archive([active.id]));
else if (active && k === "#") act(() => remove([active.id]));
else if (active && k === "s") act(() => toggleStar(active.id));
else if (active && k === "u") act(() => markRead([active.id], false));
else if (active && k === "x")
act(() =>
setSelected((s) => {
const n = new Set(s);
if (n.has(active.id)) n.delete(active.id);
else n.add(active.id);
return n;
}),
);
};
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
/* -------------------------------- render ------------------------------- */
const sidebar = (
<MailSidebar
account={account}
view={view}
counts={counts}
labelCounts={labelCounts}
labels={labels}
onView={changeView}
onCompose={() => {
setDrawer(false);
setCompose({ to: "", subject: "", body: "" });
}}
/>
);
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={compose || drawer ? true : undefined} className="flex min-w-0 flex-1">
<aside aria-label="Mailboxes" className="hidden w-60 shrink-0 border-r bg-muted/30 lg:block dark:bg-muted/20">
{sidebar}
</aside>
<section
aria-label="Conversation list"
className={cn("w-full min-w-0 shrink-0 border-r md:block md:w-[340px] xl:w-[380px]", active ? "hidden" : "block")}
>
<MessageList
title={title}
threads={visible}
account={account}
labels={labelsById}
activeId={activeId}
selected={visibleSelected}
now={now}
query={query}
unreadOnly={unreadOnly}
mode={mode}
registerSearch={registerSearch}
onQuery={setQuery}
onUnreadOnly={setUnreadOnly}
onOpen={openThread}
onToggleSelect={(id) =>
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
onSelectAll={(all) => setSelected(all ? new Set(visible.map((t) => t.id)) : new Set())}
onToggleStar={toggleStar}
onArchive={archive}
onDelete={remove}
onRestore={restore}
onMarkRead={markRead}
onOpenSidebar={() => setDrawer(true)}
/>
</section>
<main className={cn("min-w-0 flex-1 bg-background md:block", active ? "block" : "hidden")}>
<ReadingPane
thread={active}
account={account}
labels={labelsById}
now={now}
position={{ index: Math.max(0, activeIndex), total: visible.length }}
mode={mode}
onBack={() => setActiveId(null)}
onArchive={() => active && archive([active.id])}
onDelete={() => active && remove([active.id])}
onRestore={() => active && restore([active.id])}
onToggleStar={() => active && toggleStar(active.id)}
onMarkUnread={() => {
if (!active) return;
patch([active.id], (t) => ({ ...t, unread: true }));
setActiveId(null);
}}
onPrev={() => step(-1)}
onNext={() => step(1)}
onReply={reply}
/>
</main>
</div>
{/* Mobile / tablet folder drawer */}
<AnimatePresence>
{drawer && (
<>
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDrawer(false)}
className="absolute inset-0 z-30 bg-foreground/15 lg:hidden dark:bg-black/50"
/>
<motion.aside
aria-label="Mailboxes"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
className="absolute inset-y-0 left-0 z-40 w-64 border-r bg-background shadow-2xl lg:hidden"
>
<button
type="button"
aria-label="Close folders"
onClick={() => setDrawer(false)}
className="absolute right-2 top-3.5 z-10 grid size-7 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
{sidebar}
</motion.aside>
</>
)}
</AnimatePresence>
<ComposeDialog draft={compose} onClose={closeCompose} onDiscard={discardCompose} onSend={sendCompose} />
{/* Toasts */}
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-4 z-[60] flex flex-col items-center gap-2 px-4">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 24, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
role="status"
className="pointer-events-auto relative flex min-w-64 max-w-full items-center gap-2.5 overflow-hidden rounded-xl bg-foreground py-2.5 pl-3 pr-2 text-[13px] text-background shadow-xl shadow-black/20"
>
<motion.span initial={{ scale: 0, rotate: -90 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}>
<CheckCircle2 className="size-4 text-emerald-400 dark:text-emerald-600" aria-hidden />
</motion.span>
<span className="min-w-0 flex-1 truncate font-medium">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
dismiss(t.id);
}}
className="h-7 rounded-md px-2 text-xs font-semibold text-background/90 underline-offset-2 outline-none hover:bg-background/10 hover:underline focus-visible:ring-2 focus-visible:ring-background/60"
>
Undo
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="grid size-6 place-items-center rounded-md text-background/60 outline-none hover:bg-background/10 hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
<motion.span
aria-hidden
className="absolute bottom-0 left-0 h-0.5 bg-background/30"
initial={{ width: "100%" }}
animate={{ width: "0%" }}
transition={{ duration: 5, ease: "linear" }}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
export default MailApp;