"use client"; import { Archive, ArrowLeft, Inbox, MailOpen, Search, Star, Trash2, Undo2, X, } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, type PanInfo, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { Checkbox } from "@/components/motion/checkbox"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface InboxFolder { id: string; label: string; icon?: ReactNode; /** Virtual folder (e.g. Starred): decides membership instead of `message.folder`. */ filter?: (message: InboxMessage) => boolean; } function inFolder(message: InboxMessage, folder: InboxFolder) { return folder.filter ? folder.filter(message) : message.folder === folder.id; } export interface InboxMessage { id: string; folder: string; from: { name: string; email?: string }; subject: string; /** One-line preview shown in the list. */ preview: string; /** Full body for the reading pane. Strings keep their line breaks. */ body?: ReactNode; /** ISO date string; formatted with `locale` + `timeZone`. */ date: string; unread?: boolean; starred?: boolean; } export type InboxAction = "archive" | "delete"; export interface InboxAppProps { folders: InboxFolder[]; /** Controlled messages. */ messages?: InboxMessage[]; /** Uncontrolled initial messages. */ defaultMessages?: InboxMessage[]; onMessagesChange?: (messages: InboxMessage[]) => void; /** Controlled open folder. */ folder?: string; defaultFolder?: string; onFolderChange?: (folder: string) => void; /** Controlled open message id. */ selectedId?: string | null; defaultSelectedId?: string | null; onSelectedChange?: (id: string | null) => void; /** Fires after archive/delete with the affected ids (before any undo). */ onAction?: (action: InboxAction, ids: string[]) => void; /** Fires when an archive/delete is undone. */ onUndo?: (action: InboxAction, ids: string[]) => void; /** Folder that archived mail moves to. Default "archive". */ archiveFolder?: string; /** Folder that deleted mail moves to; deleting inside it removes for good. Default "trash". */ trashFolder?: string; /** How long the undo bar stays, in ms. Default 5000. */ undoTimeout?: number; /** Default "en-US" so server and client agree. */ locale?: string; /** Default "UTC" so server and client agree. */ timeZone?: string; className?: string; } // Past this drag distance (px) a row commits to archive (right) or delete (left). const SWIPE_COMMIT = 96; function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } type UndoState = { key: number; action: InboxAction; ids: string[]; previous: InboxMessage[]; }; /** * Mail client block: folder rail with unread counts, a searchable message * list (unread dots, stars, checkboxes, gliding selection) and a reading pane * that cross-fades between messages. Rows swipe right to archive and left to * delete (or use the toolbar), slide out, and an undo bar restores them. */ export function InboxApp({ folders, messages: messagesProp, defaultMessages = [], onMessagesChange, folder: folderProp, defaultFolder, onFolderChange, selectedId: selectedProp, defaultSelectedId = null, onSelectedChange, onAction, onUndo, archiveFolder = "archive", trashFolder = "trash", undoTimeout = 5000, locale = "en-US", timeZone = "UTC", className, }: InboxAppProps) { const reduce = useReducedMotion(); const baseId = useId(); const [messages, setMessages] = useControllable( messagesProp, defaultMessages, onMessagesChange, ); const [folder, setFolder] = useControllable( folderProp, defaultFolder ?? folders[0]?.id ?? "", onFolderChange, ); const [selectedId, setSelectedId] = useControllable( selectedProp, defaultSelectedId, onSelectedChange, ); const [query, setQuery] = useState(""); const [checked, setChecked] = useState>(() => new Set()); const [exitDir, setExitDir] = useState<1 | -1>(1); const [undo, setUndo] = useState(null); // Narrow containers show either the list or the reader. const [reading, setReading] = useState(false); const undoKey = useRef(0); const dateFormat = useMemo( () => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", timeZone }), [locale, timeZone], ); const fullDateFormat = useMemo( () => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone, }), [locale, timeZone], ); const activeFolder = folders.find((f) => f.id === folder); const unreadByFolder = useMemo(() => { const counts: Record = {}; for (const f of folders) { counts[f.id] = messages.filter((m) => m.unread && inFolder(m, f)).length; } return counts; }, [messages, folders]); const visible = useMemo(() => { const q = query.trim().toLowerCase(); return messages .filter((m) => (activeFolder ? inFolder(m, activeFolder) : m.folder === folder)) .filter( (m) => !q || m.subject.toLowerCase().includes(q) || m.from.name.toLowerCase().includes(q) || m.preview.toLowerCase().includes(q), ) .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); }, [messages, folder, query, activeFolder]); const selected = visible.find((m) => m.id === selectedId) ?? null; // Undo bar auto-dismisses; a new action restarts the timer via its key. useEffect(() => { if (!undo) return; const timer = window.setTimeout(() => setUndo(null), undoTimeout); return () => window.clearTimeout(timer); }, [undo, undoTimeout]); const patch = useCallback( (ids: string[], change: Partial) => { const set = new Set(ids); setMessages(messages.map((m) => (set.has(m.id) ? { ...m, ...change } : m))); }, [messages, setMessages], ); const open = (id: string) => { setSelectedId(id); setReading(true); const target = messages.find((m) => m.id === id); if (target?.unread) patch([id], { unread: false }); }; const run = (action: InboxAction, ids: string[]) => { if (ids.length === 0) return; const set = new Set(ids); const destination = action === "archive" ? archiveFolder : trashFolder; const permanent = action === "delete" && folder === trashFolder; const next = permanent ? messages.filter((m) => !set.has(m.id)) : messages.map((m) => (set.has(m.id) ? { ...m, folder: destination } : m)); // Archive slides right, delete slides left — matching the swipe directions. setExitDir(action === "archive" ? 1 : -1); setMessages(next); setChecked((prev) => { const copy = new Set(prev); for (const id of ids) copy.delete(id); return copy; }); if (selectedId && set.has(selectedId)) { // Move the reader to the next message left in the list, if any. const order = visible.map((m) => m.id); const at = order.indexOf(selectedId); const remaining = order.filter((id) => !set.has(id)); setSelectedId(remaining[Math.min(at, remaining.length - 1)] ?? null); if (remaining.length === 0) setReading(false); } undoKey.current += 1; setUndo({ key: undoKey.current, action, ids, previous: messages }); onAction?.(action, ids); }; const restore = () => { if (!undo) return; setExitDir(undo.action === "archive" ? 1 : -1); setMessages(undo.previous); onUndo?.(undo.action, undo.ids); setUndo(null); }; const checkedIds = visible.filter((m) => checked.has(m.id)).map((m) => m.id); const allChecked = visible.length > 0 && checkedIds.length === visible.length; const toggleChecked = (id: string) => setChecked((prev) => { const copy = new Set(prev); if (copy.has(id)) copy.delete(id); else copy.add(id); return copy; }); const switchFolder = (id: string) => { setFolder(id); setChecked(new Set()); setSelectedId(null); setReading(false); }; const fade = { duration: 0.2, ease: EASE_OUT }; return (
{/* Folder rail */} {/* Message list */}
{/* Narrow containers swap the folder rail for a select. */}
0 && !allChecked} onCheckedChange={() => setChecked(allChecked ? new Set() : new Set(visible.map((m) => m.id))) } className="[&>button]:size-4" /> {checkedIds.length > 0 ? ( {checkedIds.length} selected run("archive", checkedIds)}> run("delete", checkedIds)}> ) : ( {visible.length} {visible.length === 1 ? "conversation" : "conversations"} )}
    {visible.map((m) => ( open(m.id)} onCheck={() => toggleChecked(m.id)} onStar={() => patch([m.id], { starred: !m.starred })} onSwipe={(action) => run(action, [m.id])} /> ))} {visible.length === 0 ? (
  • {query ? `No mail matches “${query}”` : "Nothing here"}
  • ) : null}
{/* Reading pane */}
{selected ? (
setReading(false)} className="@xl:hidden" > run("archive", [selected.id])}> run("delete", [selected.id])}> { patch([selected.id], { unread: true }); setSelectedId(null); setReading(false); }} > patch([selected.id], { starred: !selected.starred })} className="ml-auto" >

{selected.subject}

{selected.from.name}

{selected.from.email ? (

{selected.from.email}

) : null}
{selected.body ?? selected.preview}
) : ( Select a message to read )}
{/* Undo bar */}
{undo ? ( {undo.ids.length === 1 ? "Conversation" : `${undo.ids.length} conversations`}{" "} {undo.action === "archive" ? "archived" : "deleted"} ) : null}
); } function MessageRow({ message, active, checked, date, reduce, onOpen, onCheck, onStar, onSwipe, }: { message: InboxMessage; active: boolean; checked: boolean; date: string; reduce: boolean; onOpen: () => void; onCheck: () => void; onStar: () => void; onSwipe: (action: InboxAction) => void; }) { const x = useMotionValue(0); const archiveOpacity = useTransform(x, [0, SWIPE_COMMIT], [0, 1]); const deleteOpacity = useTransform(x, [-SWIPE_COMMIT, 0], [1, 0]); const onDragEnd = (_: unknown, info: PanInfo) => { if (info.offset.x > SWIPE_COMMIT) onSwipe("archive"); else if (info.offset.x < -SWIPE_COMMIT) onSwipe("delete"); }; return ( ({ opacity: 0, x: reduce ? 0 : `${dir * 100}%`, transition: { duration: 0.22, ease: EASE_OUT }, }), }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit="exit" transition={SPRING_LAYOUT} className="relative mb-0.5 overflow-hidden rounded-lg" > {/* Swipe hints behind the row */} {active ? ( ) : checked ? ( ) : null}
); } function IconButton({ label, onClick, children, pressed, className, }: { label: string; onClick: () => void; children: ReactNode; pressed?: boolean; className?: string; }) { return ( ); } function Initials({ name }: { name: string }) { const letters = name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join(""); return ( {letters} ); }