"use client"; // ============================================================ // Mail components used INSIDE the Inbox (not a separate tab). // The Inbox is the one unified surface — mentions, system messages // and mail all live there. These render the mail body + reply, and // compose a new message. HTML bodies render in a sandboxed iframe. // ============================================================ import { type CSSProperties, useEffect, useRef, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui"; import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api"; import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api"; const timeOf = (iso?: string) => { if (!iso) return ""; const d = new Date(iso); return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }; const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)"; const inputStyle: CSSProperties = { width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", }; function fmtBytes(n: number): string { if (!n) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } /** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */ function MailAttachmentView({ att }: { att: MailAttachment }) { const getUrl = useDownloadUrl(); const [url, setUrl] = useState(null); useEffect(() => { let alive = true; getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {}); return () => { alive = false; }; }, [att.contentRef, att.mimeType, getUrl]); const label = att.filename || "Attachment"; if (isImage(att.mimeType)) { return url ? {label} :
Loading image…
; } return ( {label} {att.sizeBytes > 0 && {fmtBytes(att.sizeBytes)}} ); } /** A small staged-file chip shown in a composer before send, with a remove button. */ function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) { return (
{file.filename} {fmtBytes(file.sizeBytes)}
); } /** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) { const t = useMailThread(threadId); const upload = useUploadAttachment(); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [staged, setStaged] = useState(null); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); async function onPickFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; setUploading(true); try { setStaged(await upload(file)); } catch (err) { onError((err as Error).message); } finally { setUploading(false); } } async function reply() { const text = draft.trim(); if ((!text && !staged) || sending) return; const att = staged ?? undefined; setDraft(""); setStaged(null); setSending(true); try { await t.reply(text, att); } catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); } finally { setSending(false); } } return (
{subject || "(no subject)"}
{t.loading && t.messages.length === 0 &&
Loading…
} {!t.loading && t.messages.length === 0 &&
No messages.
} {t.messages.map((m) => (
{m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""} {timeOf(m.occurredAt)}
{m.html ?