Fazekit

Code

"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;