344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
import {
|
|
Archive,
|
|
ArrowLeft,
|
|
Clock,
|
|
Forward,
|
|
Mail,
|
|
MailOpen,
|
|
MoreHorizontal,
|
|
Reply,
|
|
RotateCcw,
|
|
Star,
|
|
Tag,
|
|
FolderInput,
|
|
ShieldAlert,
|
|
Trash2,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import AgentSpark from "./AgentSpark";
|
|
import SenderAvatar from "./SenderAvatar";
|
|
import { addressText, senderDisplayName } from "../utils/senderAvatar";
|
|
import { attachmentCount } from "../utils/inboxSignals";
|
|
|
|
const ICON = { size: 17, strokeWidth: 1.7 };
|
|
|
|
function joinAddresses(value) {
|
|
if (!value) return "";
|
|
const addresses = Array.isArray(value) ? value : [value];
|
|
return addresses
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (item?.name && item?.address) return `${item.name} <${item.address}>`;
|
|
return item?.email || item?.address || "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
}
|
|
|
|
function messageBody(email) {
|
|
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
|
|
if (text) return { mode: "text", value: text };
|
|
const html = (email?.body_html || "").trim();
|
|
if (html) return { mode: "html", value: html };
|
|
const snippet = (email?.snippet || "").trim();
|
|
if (snippet) return { mode: "text", value: snippet };
|
|
return { mode: "text", value: "This message has no readable content." };
|
|
}
|
|
|
|
function plainTextParagraphs(text) {
|
|
return text
|
|
.split(/\n{2,}/)
|
|
.map((block) => block.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function detailTime(email) {
|
|
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
|
if (!value) return { absolute: "", relative: "" };
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return { absolute: "", relative: "" };
|
|
const absolute = new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
}).format(date);
|
|
const diffMs = Date.now() - date.getTime();
|
|
const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60)));
|
|
const relative = diffHours < 24 ? `${diffHours}h ago` : `${Math.round(diffHours / 24)}d ago`;
|
|
return { absolute, relative };
|
|
}
|
|
|
|
function senderIdentity(email) {
|
|
const name = senderDisplayName(email);
|
|
const address = addressText(email.sender || email.from);
|
|
if (name && address && !name.includes("@")) {
|
|
return `${name} <${address}>`;
|
|
}
|
|
return address || name || "Unknown sender";
|
|
}
|
|
|
|
function recipientLabel(email, viewerEmail) {
|
|
const recipients = email.recipients || email.to || [];
|
|
const list = Array.isArray(recipients) ? recipients : [recipients];
|
|
const addresses = list
|
|
.map((item) => (typeof item === "string" ? item : item?.address || item?.email || ""))
|
|
.filter(Boolean);
|
|
if (!addresses.length) return "Undisclosed recipients";
|
|
if (viewerEmail && addresses.length === 1 && addresses[0].toLowerCase() === viewerEmail.toLowerCase()) {
|
|
return "to me";
|
|
}
|
|
if (addresses.length === 1) return `to ${addresses[0]}`;
|
|
return `to ${addresses.length} recipients`;
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!bytes) return "";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function mailboxLabel(mailbox) {
|
|
if (mailbox === "inbox") return "Inbox";
|
|
if (mailbox === "sent") return "Sent";
|
|
if (mailbox === "trash") return "Trash";
|
|
return mailbox;
|
|
}
|
|
|
|
function ToolbarButton({ title, label, onClick, disabled, danger, children }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={danger ? "danger" : ""}
|
|
title={title}
|
|
aria-label={label || title}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function MailDetail({
|
|
email,
|
|
loading,
|
|
error,
|
|
mailbox,
|
|
busy,
|
|
viewerEmail,
|
|
threadSize = 1,
|
|
onClose,
|
|
onToggleRead,
|
|
onToggleStar,
|
|
onTrash,
|
|
onRestore,
|
|
onDelete,
|
|
onArchive,
|
|
onSpam,
|
|
onSnooze,
|
|
onMove,
|
|
onReply,
|
|
onForward,
|
|
onExtractActions,
|
|
onSummarizeThread,
|
|
onAnalyzeAttachment,
|
|
onDraftReply,
|
|
}) {
|
|
const files = email ? attachmentCount(email) : 0;
|
|
const body = email ? messageBody(email) : null;
|
|
const time = email ? detailTime(email) : { absolute: "", relative: "" };
|
|
const attachments = email?.attachments || [];
|
|
const summarizeLabel = threadSize > 1 ? "Summarize thread" : "Summarize email";
|
|
|
|
return (
|
|
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
|
|
<header className="mail-detail-toolbar">
|
|
<div className="mail-detail-toolbar-group">
|
|
<ToolbarButton title="Back to inbox" label="Back" onClick={onClose}>
|
|
<ArrowLeft {...ICON} />
|
|
</ToolbarButton>
|
|
</div>
|
|
{email && (
|
|
<>
|
|
<div className="mail-detail-toolbar-group">
|
|
<ToolbarButton title="Archive" onClick={onArchive} disabled={busy}>
|
|
<Archive {...ICON} />
|
|
</ToolbarButton>
|
|
<ToolbarButton title="Report spam" onClick={onSpam} disabled={busy}>
|
|
<ShieldAlert {...ICON} />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
title={mailbox === "trash" ? "Delete permanently" : "Delete"}
|
|
onClick={mailbox === "trash" ? onDelete : onTrash}
|
|
disabled={busy}
|
|
danger
|
|
>
|
|
<Trash2 {...ICON} />
|
|
</ToolbarButton>
|
|
</div>
|
|
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
|
|
<div className="mail-detail-toolbar-group">
|
|
<ToolbarButton
|
|
title={email.is_read ? "Mark unread" : "Mark read"}
|
|
onClick={() => onToggleRead(!email.is_read)}
|
|
disabled={busy}
|
|
>
|
|
{email.is_read ? <Mail {...ICON} /> : <MailOpen {...ICON} />}
|
|
</ToolbarButton>
|
|
<ToolbarButton title="Snooze" onClick={onSnooze} disabled={busy}>
|
|
<Clock {...ICON} />
|
|
</ToolbarButton>
|
|
</div>
|
|
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
|
|
<div className="mail-detail-toolbar-group">
|
|
<ToolbarButton title="Move to" onClick={onMove} disabled={busy}>
|
|
<FolderInput {...ICON} />
|
|
</ToolbarButton>
|
|
<ToolbarButton title="Labels" onClick={onMove} disabled={busy}>
|
|
<Tag {...ICON} />
|
|
</ToolbarButton>
|
|
<ToolbarButton title="More actions" onClick={onMove} disabled={busy}>
|
|
<MoreHorizontal {...ICON} />
|
|
</ToolbarButton>
|
|
</div>
|
|
{mailbox === "trash" && (
|
|
<>
|
|
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
|
|
<div className="mail-detail-toolbar-group">
|
|
<ToolbarButton title="Restore" onClick={onRestore} disabled={busy}>
|
|
<RotateCcw {...ICON} />
|
|
</ToolbarButton>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</header>
|
|
|
|
{loading && <div className="mail-detail-state">Opening message…</div>}
|
|
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
|
|
|
|
{email && !loading && (
|
|
<div className="mail-detail-content">
|
|
<div className="mail-detail-subject-row">
|
|
<div className="mail-detail-subject-main">
|
|
<h1>{email.subject?.trim() || "(No subject)"}</h1>
|
|
<div className="mail-detail-labels">
|
|
<span className="mail-detail-label-pill">{mailboxLabel(mailbox)}</span>
|
|
{email.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
|
|
</div>
|
|
</div>
|
|
<div className="mail-detail-ai-chips">
|
|
<button type="button" className="mail-detail-ai-chip" onClick={onSummarizeThread}>
|
|
<AgentSpark /> {summarizeLabel}
|
|
</button>
|
|
<button type="button" className="mail-detail-ai-chip" onClick={onExtractActions}>
|
|
<AgentSpark /> Extract actions
|
|
</button>
|
|
<button type="button" className="mail-detail-ai-chip" onClick={onDraftReply}>
|
|
<AgentSpark /> Draft reply
|
|
</button>
|
|
{files > 0 && (
|
|
<button type="button" className="mail-detail-ai-chip" onClick={onAnalyzeAttachment}>
|
|
<AgentSpark /> Analyze attachment
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mail-detail-sender-row">
|
|
<div className="mail-detail-sender-left">
|
|
<SenderAvatar email={email} />
|
|
<div className="mail-detail-sender-meta">
|
|
<div className="mail-detail-sender-line">
|
|
<strong>{senderIdentity(email)}</strong>
|
|
</div>
|
|
<button type="button" className="mail-detail-recipient-toggle">
|
|
{recipientLabel(email, viewerEmail)} <ChevronDown size={12} strokeWidth={1.75} />
|
|
</button>
|
|
<div className="mail-detail-recipient-details">
|
|
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
|
|
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mail-detail-sender-actions">
|
|
<time dateTime={email.created_at}>
|
|
{time.absolute}
|
|
{time.relative ? ` (${time.relative})` : ""}
|
|
</time>
|
|
<button
|
|
type="button"
|
|
className={`mail-detail-icon-btn${email.is_starred ? " active" : ""}`}
|
|
title={email.is_starred ? "Unstar" : "Star"}
|
|
aria-label={email.is_starred ? "Unstar" : "Star"}
|
|
onClick={() => onToggleStar?.(!email.is_starred)}
|
|
>
|
|
<Star size={16} strokeWidth={1.75} />
|
|
</button>
|
|
<button type="button" className="mail-detail-icon-btn" title="Reply" onClick={onReply}>
|
|
<Reply size={16} strokeWidth={1.75} />
|
|
</button>
|
|
<button type="button" className="mail-detail-icon-btn" title="More" onClick={onMove}>
|
|
<MoreHorizontal size={16} strokeWidth={1.75} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
|
{body?.mode === "html" ? (
|
|
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
|
) : (
|
|
<div className="mail-detail-body-text">
|
|
{plainTextParagraphs(body?.value || "").map((paragraph) => (
|
|
<p key={paragraph.slice(0, 24)}>
|
|
{paragraph.split("\n").map((line, index, lines) => (
|
|
<span key={`${line}-${index}`}>
|
|
{line}
|
|
{index < lines.length - 1 && <br />}
|
|
</span>
|
|
))}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="mail-detail-attachments" aria-label="Attachments">
|
|
{attachments.map((file) => (
|
|
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
|
|
<span className="mail-detail-attachment-icon">📄</span>
|
|
<div>
|
|
<strong>{file.filename}</strong>
|
|
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
|
|
</div>
|
|
<div className="mail-detail-attachment-actions">
|
|
<button type="button">Preview</button>
|
|
<button type="button">Download</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<footer className="mail-detail-reply-bar">
|
|
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
|
|
<Reply size={15} strokeWidth={1.75} /> Reply
|
|
</button>
|
|
{(email.recipients?.length > 1 || email.cc?.length) && (
|
|
<button type="button" className="mail-detail-reply-btn subtle" onClick={onReply}>
|
|
Reply all
|
|
</button>
|
|
)}
|
|
<button type="button" className="mail-detail-reply-btn subtle" onClick={onForward}>
|
|
<Forward size={15} strokeWidth={1.75} /> Forward
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|