1008 lines
36 KiB
JavaScript
1008 lines
36 KiB
JavaScript
import {
|
||
Bell,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
CircleHelp,
|
||
Filter,
|
||
LayoutGrid,
|
||
Mail,
|
||
MoreHorizontal,
|
||
RefreshCw,
|
||
Search,
|
||
Settings,
|
||
Sparkles,
|
||
Sun,
|
||
Tag,
|
||
Users,
|
||
Wallet,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
getEmail,
|
||
getThread,
|
||
listThreads,
|
||
permanentlyDeleteEmail,
|
||
restoreEmail,
|
||
setEmailRead,
|
||
setEmailStar,
|
||
trashEmail,
|
||
} from "../api";
|
||
import AgAgentPanel from "./AgAgentPanel";
|
||
import AgentSpark from "./AgentSpark";
|
||
import CommandPalette from "./CommandPalette";
|
||
import { useAgentInsight } from "../hooks/useAgentInsight";
|
||
import { useMailboxMaturity } from "../hooks/useMailboxMaturity";
|
||
import MailDetail from "./MailDetail";
|
||
import MailRow from "./MailRow";
|
||
import MailboxEmptyState from "./MailboxEmptyState";
|
||
import MailboxSelectionBar from "./MailboxSelectionBar";
|
||
import StickyNotesPanel from "./StickyNotesPanel";
|
||
import WorkspaceAccountMenu from "./WorkspaceAccountMenu";
|
||
import { useShellMode } from "../shellMode";
|
||
import { shellTitle } from "./MobileChrome";
|
||
import {
|
||
categoryCounts,
|
||
emailCategory,
|
||
inboxIntelligence,
|
||
labelCounts,
|
||
matchesLens,
|
||
} from "../utils/inboxSignals";
|
||
import { buildReplyBodyHtml } from "../utils/emailReply";
|
||
|
||
const PAGE_SIZE_KEY = "agmail_page_size";
|
||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||
|
||
function readPageSize() {
|
||
const stored = Number(window.localStorage.getItem(PAGE_SIZE_KEY));
|
||
return PAGE_SIZE_OPTIONS.includes(stored) ? stored : 50;
|
||
}
|
||
|
||
const CATEGORY_TABS = [
|
||
{ name: "Primary", icon: LayoutGrid },
|
||
{ name: "Updates", icon: Bell },
|
||
{ name: "Promotions", icon: Tag },
|
||
{ name: "Social", icon: Users },
|
||
{ name: "Finance", icon: Wallet },
|
||
{ name: "All Mail", icon: Mail },
|
||
];
|
||
|
||
function resultPage(result) {
|
||
if (Array.isArray(result)) return { items: result, nextCursor: null, total: result.length };
|
||
return {
|
||
items: result.items || result.emails || result.results || [],
|
||
nextCursor: result.next_cursor || result.nextCursor || null,
|
||
total: Number(result.total || 0),
|
||
};
|
||
}
|
||
|
||
function threadToRow(thread) {
|
||
const primarySender = thread.senders?.[0] || thread.participants?.[0] || { name: "Unknown", address: "" };
|
||
return {
|
||
id: thread.latest_message_id,
|
||
thread_id: thread.id,
|
||
mailbox: thread.mailbox,
|
||
sender: primarySender,
|
||
recipients: thread.participants || [],
|
||
subject: thread.subject,
|
||
snippet: thread.snippet,
|
||
created_at: thread.latest_at,
|
||
is_read: thread.is_read,
|
||
is_starred: thread.is_starred,
|
||
attachments: thread.has_attachments ? [{ filename: "attachment" }] : [],
|
||
message_count: thread.message_count,
|
||
unread_count: thread.unread_count,
|
||
};
|
||
}
|
||
|
||
function buildReplyContext(message, replyAll = false) {
|
||
const sender = message?.sender || { name: "", address: "" };
|
||
const subject = (message?.subject || "").trim();
|
||
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
|
||
return {
|
||
replyToMessageId: message.id,
|
||
replyAll,
|
||
to: replyAll ? [] : [{ email: sender.address, name: sender.name }],
|
||
subject: replySubject,
|
||
bodyHtml: buildReplyBodyHtml(message),
|
||
};
|
||
}
|
||
|
||
export default function MailboxView({
|
||
mailbox,
|
||
title,
|
||
refreshKey = 0,
|
||
account,
|
||
lens = "all",
|
||
agentNav = null,
|
||
initialThreadId = null,
|
||
onLogout,
|
||
onOpenSettings,
|
||
onOpenProfile,
|
||
onAccountUpdate,
|
||
onCompose,
|
||
onStats,
|
||
}) {
|
||
const token = sessionStorage.getItem("agmail_access_token");
|
||
const { sheet, setSheet } = useShellMode();
|
||
const [emails, setEmails] = useState([]);
|
||
const [nextCursor, setNextCursor] = useState(null);
|
||
const [total, setTotal] = useState(0);
|
||
const [pageSize, setPageSize] = useState(readPageSize);
|
||
const [pageMenuOpen, setPageMenuOpen] = useState(false);
|
||
const [cursor, setCursor] = useState(null);
|
||
const [cursorHistory, setCursorHistory] = useState([]);
|
||
const [selected, setSelected] = useState(null);
|
||
const [checked, setChecked] = useState({});
|
||
const [hidden, setHidden] = useState({});
|
||
const [detail, setDetail] = useState(null);
|
||
const [threadDetail, setThreadDetail] = useState(null);
|
||
const [status, setStatus] = useState({ loading: true, error: "" });
|
||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||
const [category, setCategory] = useState("Primary");
|
||
const [agentFocus, setAgentFocus] = useState("");
|
||
const { insight, runInsight } = useAgentInsight();
|
||
const [commandOpen, setCommandOpen] = useState(false);
|
||
const [filters, setFilters] = useState({
|
||
query: "",
|
||
read: "all",
|
||
sender: "",
|
||
dateFrom: "",
|
||
dateTo: "",
|
||
});
|
||
|
||
const load = useCallback(async ({ silent = false } = {}) => {
|
||
if (!silent) {
|
||
setStatus({ loading: true, error: "" });
|
||
}
|
||
try {
|
||
const result = await listThreads(
|
||
{
|
||
mailbox,
|
||
limit: pageSize,
|
||
cursor,
|
||
q: filters.query.trim(),
|
||
is_read: filters.read === "all" ? undefined : filters.read === "read",
|
||
sender: filters.sender.trim(),
|
||
date_from: filters.dateFrom || undefined,
|
||
date_to: filters.dateTo || undefined,
|
||
},
|
||
token,
|
||
);
|
||
const page = resultPage(result);
|
||
setEmails(page.items.map(threadToRow));
|
||
setNextCursor(page.nextCursor);
|
||
setTotal(page.total);
|
||
setStatus({ loading: false, error: "" });
|
||
} catch (error) {
|
||
if (!silent) {
|
||
setStatus({ loading: false, error: error.message });
|
||
}
|
||
}
|
||
}, [cursor, filters, mailbox, pageSize, token]);
|
||
|
||
const canAutoRefresh = !cursor
|
||
&& !filters.query.trim()
|
||
&& filters.read === "all"
|
||
&& !filters.sender.trim()
|
||
&& !filters.dateFrom
|
||
&& !filters.dateTo;
|
||
|
||
useEffect(() => {
|
||
const timeout = window.setTimeout(() => load(), filters.query ? 250 : 0);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [load, refreshKey]);
|
||
|
||
useEffect(() => {
|
||
if (!canAutoRefresh) return undefined;
|
||
const intervalId = window.setInterval(() => {
|
||
if (document.visibilityState === "visible") {
|
||
load({ silent: true });
|
||
}
|
||
}, 30000);
|
||
return () => window.clearInterval(intervalId);
|
||
}, [canAutoRefresh, load]);
|
||
|
||
useEffect(() => {
|
||
function handleVisibilityChange() {
|
||
if (document.visibilityState === "visible" && canAutoRefresh) {
|
||
load({ silent: true });
|
||
}
|
||
}
|
||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||
}, [canAutoRefresh, load]);
|
||
|
||
useEffect(() => {
|
||
if (!initialThreadId) return undefined;
|
||
let cancelled = false;
|
||
setSelected(initialThreadId);
|
||
setDetailStatus({ loading: true, error: "", busy: false });
|
||
getThread(initialThreadId, token)
|
||
.then((thread) => {
|
||
if (cancelled) return;
|
||
const messages = thread.messages || [];
|
||
const latest = messages[messages.length - 1];
|
||
setThreadDetail(thread);
|
||
setDetail(latest || {
|
||
id: thread.latest_message_id,
|
||
thread_id: initialThreadId,
|
||
subject: thread.subject,
|
||
snippet: thread.snippet,
|
||
});
|
||
setDetailStatus({ loading: false, error: "", busy: false });
|
||
})
|
||
.catch((error) => {
|
||
if (cancelled) return;
|
||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [initialThreadId, token]);
|
||
|
||
useEffect(() => {
|
||
function openPalette(event) {
|
||
const target = event.target;
|
||
const typing =
|
||
target instanceof HTMLInputElement
|
||
|| target instanceof HTMLTextAreaElement
|
||
|| target instanceof HTMLSelectElement
|
||
|| target?.isContentEditable;
|
||
|
||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||
event.preventDefault();
|
||
setCommandOpen(true);
|
||
return;
|
||
}
|
||
if (event.key === "/" && !typing && !commandOpen) {
|
||
event.preventDefault();
|
||
setCommandOpen(true);
|
||
}
|
||
}
|
||
window.addEventListener("keydown", openPalette);
|
||
return () => window.removeEventListener("keydown", openPalette);
|
||
}, [commandOpen]);
|
||
|
||
const visibleEmails = useMemo(
|
||
() => emails.filter((email) => !hidden[email.id] && matchesLens(email, lens)),
|
||
[emails, hidden, lens],
|
||
);
|
||
|
||
const listedEmails = useMemo(() => {
|
||
if (mailbox !== "inbox" || category === "All Mail") return visibleEmails;
|
||
return visibleEmails.filter((email) => emailCategory(email) === category);
|
||
}, [category, mailbox, visibleEmails]);
|
||
|
||
const counts = useMemo(() => categoryCounts(visibleEmails), [visibleEmails]);
|
||
const selectedIds = useMemo(
|
||
() => Object.keys(checked).filter((id) => checked[id]),
|
||
[checked],
|
||
);
|
||
const selectedCount = selectedIds.length;
|
||
const hasFilters = filters.read !== "all" || Boolean(filters.sender.trim())
|
||
|| Boolean(filters.dateFrom) || Boolean(filters.dateTo);
|
||
const hasSearch = Boolean(filters.query.trim());
|
||
const { isBrandNew } = useMailboxMaturity(account, emails, status.loading);
|
||
const isEmpty = !status.loading && !status.error && listedEmails.length === 0;
|
||
|
||
useEffect(() => {
|
||
if (!onStats) return;
|
||
const intel = inboxIntelligence(emails);
|
||
onStats({
|
||
unread: emails.filter((email) => email.is_read === false).length,
|
||
starred: emails.filter((email) => email.is_starred).length,
|
||
important: intel.important,
|
||
drafts: 0,
|
||
actionItems: intel.actionItems,
|
||
labels: labelCounts(emails),
|
||
});
|
||
}, [emails, onStats]);
|
||
|
||
useEffect(() => {
|
||
setCursor(null);
|
||
setCursorHistory([]);
|
||
setChecked({});
|
||
setPageMenuOpen(false);
|
||
}, [mailbox]);
|
||
|
||
useEffect(() => {
|
||
if (!pageMenuOpen) return undefined;
|
||
function closeMenu() {
|
||
setPageMenuOpen(false);
|
||
}
|
||
window.addEventListener("click", closeMenu);
|
||
return () => window.removeEventListener("click", closeMenu);
|
||
}, [pageMenuOpen]);
|
||
|
||
function changeFilter(name, value) {
|
||
setFilters((current) => ({ ...current, [name]: value }));
|
||
setCursor(null);
|
||
setCursorHistory([]);
|
||
}
|
||
|
||
async function openEmail(email) {
|
||
setSelected(email.thread_id || email.id);
|
||
setDetail(email);
|
||
setThreadDetail(null);
|
||
setDetailStatus({ loading: true, error: "", busy: false });
|
||
if (email.is_read === false) {
|
||
setEmails((current) =>
|
||
current.map((item) => (
|
||
item.thread_id === email.thread_id || item.id === email.id
|
||
? { ...item, is_read: true }
|
||
: item
|
||
)),
|
||
);
|
||
setDetail((current) => ({ ...current, is_read: true }));
|
||
setEmailRead(email.id, true, token).catch(() => {
|
||
setEmails((current) =>
|
||
current.map((item) => (
|
||
item.thread_id === email.thread_id || item.id === email.id
|
||
? { ...item, is_read: false }
|
||
: item
|
||
)),
|
||
);
|
||
setDetail((current) =>
|
||
current?.id === email.id ? { ...current, is_read: false } : current,
|
||
);
|
||
});
|
||
}
|
||
try {
|
||
if (email.thread_id) {
|
||
const thread = await getThread(email.thread_id, token);
|
||
const messages = thread.messages || [];
|
||
const latest = messages[messages.length - 1] || email;
|
||
setThreadDetail(thread);
|
||
setDetail((current) => ({
|
||
...current,
|
||
...latest,
|
||
is_read: current?.is_read ?? latest.is_read,
|
||
}));
|
||
} else {
|
||
const completeEmail = await getEmail(email.id, token);
|
||
setDetail((current) => ({
|
||
...current,
|
||
...completeEmail,
|
||
is_read: current?.is_read ?? completeEmail.is_read,
|
||
}));
|
||
}
|
||
setDetailStatus({ loading: false, error: "", busy: false });
|
||
} catch (error) {
|
||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||
}
|
||
}
|
||
|
||
async function toggleStar(email, isStarred) {
|
||
setEmails((current) =>
|
||
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
|
||
);
|
||
if (detail?.id === email.id) {
|
||
setDetail((current) => ({ ...current, is_starred: isStarred }));
|
||
}
|
||
try {
|
||
await setEmailStar(email.id, isStarred, token);
|
||
} catch {
|
||
setEmails((current) =>
|
||
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
|
||
);
|
||
if (detail?.id === email.id) {
|
||
setDetail((current) => ({ ...current, is_starred: !isStarred }));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function markUnread(email) {
|
||
setEmails((current) =>
|
||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||
);
|
||
try {
|
||
await setEmailRead(email.id, false, token);
|
||
} catch {
|
||
setEmails((current) =>
|
||
current.map((item) => item.id === email.id ? { ...item, is_read: email.is_read } : item),
|
||
);
|
||
}
|
||
}
|
||
|
||
async function removeEmail(email) {
|
||
setEmails((current) => current.filter((item) => item.id !== email.id));
|
||
if (detail?.id === email.id) {
|
||
setSelected(null);
|
||
setDetail(null);
|
||
}
|
||
try {
|
||
await trashEmail(email.id, token);
|
||
} catch (error) {
|
||
setAgentFocus(error.message);
|
||
load();
|
||
}
|
||
}
|
||
|
||
function hideForSession(email, message) {
|
||
setHidden((current) => ({ ...current, [email.id]: true }));
|
||
if (detail?.id === email.id) {
|
||
setSelected(null);
|
||
setDetail(null);
|
||
}
|
||
setAgentFocus(message);
|
||
}
|
||
|
||
function summarize(email) {
|
||
const subject = email.subject?.trim() || "(No subject)";
|
||
const snippet = email.snippet || email.preview || email.body_text || "No preview available.";
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: subject,
|
||
summary: snippet,
|
||
details: [
|
||
`From ${email.sender?.name || email.sender?.email || "unknown sender"}`,
|
||
email.is_read === false ? "Marked unread — likely needs attention" : "Already read",
|
||
],
|
||
actions: ["Draft a reply", "Extract action items", "Snooze for later"],
|
||
});
|
||
}
|
||
|
||
function summarizeSelection() {
|
||
const items = listedEmails.filter((email) => checked[email.id]);
|
||
if (!items.length) return;
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: `${items.length} selected messages`,
|
||
summary: items.length === 1
|
||
? (items[0].snippet || items[0].preview || "No preview available.")
|
||
: `${items.length} conversations span ${items.filter((email) => email.is_read === false).length} unread threads.`,
|
||
details: items.slice(0, 3).map((email) => email.subject || "(No subject)"),
|
||
actions: ["Draft batch replies", "Archive completed threads", "Flag for follow-up"],
|
||
});
|
||
}
|
||
|
||
function runBulkAction(action) {
|
||
const items = listedEmails.filter((email) => checked[email.id]);
|
||
if (!items.length) return;
|
||
if (action === "archive" || action === "snooze") {
|
||
items.forEach((email) => hideForSession(
|
||
email,
|
||
action === "archive"
|
||
? "Archived in this view until you refresh."
|
||
: "Snoozed in this view until you refresh.",
|
||
));
|
||
}
|
||
if (action === "delete" || action === "spam") {
|
||
items.forEach((email) => removeEmail(email));
|
||
}
|
||
if (action === "unread") {
|
||
items.forEach((email) => markUnread(email));
|
||
}
|
||
if (action === "move" || action === "label" || action === "more") {
|
||
setAgentFocus(`${action === "move" ? "Move" : action === "label" ? "Label" : "More"} for ${items.length} selected messages is coming soon.`);
|
||
}
|
||
setChecked({});
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
if (selectedCount === listedEmails.length) {
|
||
setChecked({});
|
||
return;
|
||
}
|
||
setChecked(listedEmails.reduce((map, email) => ({ ...map, [email.id]: true }), {}));
|
||
}
|
||
|
||
function extractActions(email) {
|
||
const text = email.body_text || email.snippet || email.preview || "";
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: email.subject || "Message",
|
||
summary: text
|
||
? "Scanned the message for commitments, requests, and deadlines."
|
||
: "No plain-text content to extract actions from.",
|
||
details: text
|
||
? ["Reply requested or implied", "Review attachments if any", "Check calendar mentions"]
|
||
: ["Open the full message to extract richer actions"],
|
||
actions: ["Draft a reply", "Add to action items", "Set a reminder"],
|
||
});
|
||
}
|
||
|
||
function summarizeThread(email) {
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: email.subject || "Thread",
|
||
summary: email.snippet || email.preview || "Thread context loaded from the latest message.",
|
||
details: ["Prior messages in thread considered", "Open questions highlighted"],
|
||
actions: ["Draft a reply", "Summarize for your team", "Extract next steps"],
|
||
});
|
||
}
|
||
|
||
function analyzeAttachment(email) {
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: email.subject || "Attachments",
|
||
summary: "Reviewing files attached to this conversation.",
|
||
details: ["File types identified", "Key entities extracted from filenames"],
|
||
actions: ["Summarize document", "Find related messages", "Save to notes"],
|
||
});
|
||
}
|
||
|
||
function runQuickAction(action) {
|
||
const intel = inboxIntelligence(listedEmails);
|
||
if (action === "summarize") {
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: `${category} inbox`,
|
||
summary: listedEmails.length
|
||
? `${listedEmails.length} messages loaded. ${intel.actionItems} look like they need a reply.`
|
||
: "Nothing in this view to summarize yet.",
|
||
details: [
|
||
`${intel.meetings} mention meetings`,
|
||
`${intel.invoices} look like bills`,
|
||
`${intel.unread} unread overall`,
|
||
],
|
||
actions: ["Show action items", "Draft replies", "Find attachments"],
|
||
});
|
||
return;
|
||
}
|
||
if (action === "draft") {
|
||
const first = listedEmails.find((email) => email.is_read === false) || listedEmails[0];
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: first?.subject || "Draft replies",
|
||
summary: first
|
||
? `Ready to draft a reply to "${first.subject || "the latest message"}".`
|
||
: "No message is waiting for a reply.",
|
||
details: first ? ["Tone: professional and concise", "Context pulled from inbox signals"] : [],
|
||
actions: ["Open compose", "Suggest three reply options", "Improve existing draft"],
|
||
});
|
||
return;
|
||
}
|
||
if (action === "unread") {
|
||
changeFilter("read", "unread");
|
||
setFiltersOpen(true);
|
||
setAgentFocus(`${intel.unread} unread messages in this mailbox.`);
|
||
return;
|
||
}
|
||
if (action === "attachments" || action === "actions" || action === "important" || action === "actionItems" || action === "meetings" || action === "invoices") {
|
||
const labels = {
|
||
attachments: "attachments",
|
||
actions: "action items",
|
||
actionItems: "action items",
|
||
important: "important mail",
|
||
meetings: "meetings",
|
||
invoices: "invoices",
|
||
};
|
||
setCategory(action === "invoices" ? "Finance" : action === "important" ? "Primary" : category);
|
||
setAgentFocus(`Showing signals for ${labels[action] || action} from the messages already loaded.`);
|
||
}
|
||
}
|
||
|
||
function handleCommand(action, query = "") {
|
||
if (action === "search") {
|
||
if (query.trim()) changeFilter("query", query.trim());
|
||
window.setTimeout(() => document.getElementById("workspace-search")?.focus(), 0);
|
||
return;
|
||
}
|
||
if (action === "from") {
|
||
setFiltersOpen(true);
|
||
if (query.trim()) changeFilter("sender", query.trim());
|
||
setAgentFocus("Find emails from a specific person — use the sender filter.");
|
||
return;
|
||
}
|
||
runQuickAction(action);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!agentNav || status.loading) return;
|
||
if (agentNav === "integrations") {
|
||
setAgentFocus("Connect CRM, calendar, and billing tools from Integrations.");
|
||
return;
|
||
}
|
||
if (agentNav === "ask" || agentNav === "summaries") {
|
||
runQuickAction("summarize");
|
||
return;
|
||
}
|
||
if (agentNav === "actions") {
|
||
runQuickAction("actions");
|
||
}
|
||
}, [agentNav, status.loading, listedEmails.length, category]);
|
||
|
||
async function toggleRead(isRead) {
|
||
if (!detail) return;
|
||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||
try {
|
||
await setEmailRead(detail.id, isRead, token);
|
||
setDetail((current) => ({ ...current, is_read: isRead }));
|
||
setEmails((current) =>
|
||
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
|
||
);
|
||
setDetailStatus((current) => ({ ...current, busy: false }));
|
||
} catch (error) {
|
||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||
}
|
||
}
|
||
|
||
async function mutateSelected(action) {
|
||
if (!detail) return;
|
||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||
try {
|
||
await action(detail.id, token);
|
||
setEmails((current) => current.filter((item) => item.id !== detail.id));
|
||
setSelected(null);
|
||
setDetail(null);
|
||
setDetailStatus({ loading: false, error: "", busy: false });
|
||
} catch (error) {
|
||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||
}
|
||
}
|
||
|
||
function deletePermanently() {
|
||
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
|
||
mutateSelected(permanentlyDeleteEmail);
|
||
}
|
||
}
|
||
|
||
function nextPage() {
|
||
if (!nextCursor) return;
|
||
setCursorHistory((history) => [...history, cursor]);
|
||
setCursor(nextCursor);
|
||
setSelected(null);
|
||
setDetail(null);
|
||
setChecked({});
|
||
}
|
||
|
||
function previousPage() {
|
||
if (!cursorHistory.length) return;
|
||
const previous = cursorHistory[cursorHistory.length - 1];
|
||
setCursorHistory((history) => history.slice(0, -1));
|
||
setCursor(previous);
|
||
setSelected(null);
|
||
setDetail(null);
|
||
setChecked({});
|
||
}
|
||
|
||
function changePageSize(size) {
|
||
if (size === pageSize) {
|
||
setPageMenuOpen(false);
|
||
return;
|
||
}
|
||
window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
|
||
setPageSize(size);
|
||
setCursor(null);
|
||
setCursorHistory([]);
|
||
setChecked({});
|
||
setPageMenuOpen(false);
|
||
}
|
||
|
||
const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
|
||
const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0;
|
||
const rangeLabel = emails.length === 0
|
||
? `0–0 of ${total}`
|
||
: `${rangeStart}–${rangeEnd} of ${total || rangeEnd}`;
|
||
const allSelected = listedEmails.length > 0 && selectedCount === listedEmails.length;
|
||
const someSelected = selectedCount > 0 && !allSelected;
|
||
|
||
return (
|
||
<section className="mailbox-workspace">
|
||
<header className="workspace-header">
|
||
<div className="workspace-zone workspace-zone-command">
|
||
<label className="workspace-search">
|
||
<Search size={16} />
|
||
<input
|
||
id="workspace-search"
|
||
type="search"
|
||
value={filters.query}
|
||
onChange={(event) => changeFilter("query", event.target.value)}
|
||
placeholder="Search emails, people, or ask your agent…"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="workspace-search-kbd"
|
||
aria-label="Open command palette"
|
||
onClick={() => setCommandOpen(true)}
|
||
>
|
||
<kbd>⌘K</kbd>
|
||
</button>
|
||
</label>
|
||
</div>
|
||
<div className="workspace-zone workspace-zone-actions">
|
||
<button
|
||
className={`workspace-filter${filtersOpen || hasFilters ? " active" : ""}`}
|
||
type="button"
|
||
onClick={() => setFiltersOpen((open) => !open)}
|
||
aria-expanded={filtersOpen}
|
||
aria-label="Advanced filters"
|
||
>
|
||
<Filter size={16} />
|
||
</button>
|
||
<button className="workspace-ask agent-action" type="button" onClick={() => runQuickAction("summarize")}>
|
||
<AgentSpark /> Ask Agent
|
||
</button>
|
||
<button
|
||
className="shell-agent-toggle"
|
||
type="button"
|
||
aria-label="Open AG Agent"
|
||
onClick={() => setSheet(sheet === "agent" ? null : "agent")}
|
||
>
|
||
<Sparkles size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="workspace-zone workspace-zone-account">
|
||
<button type="button" className="workspace-icon" aria-label="Refresh" onClick={load}><RefreshCw size={16} /></button>
|
||
<button type="button" className="workspace-icon" aria-label="Theme"><Sun size={16} /></button>
|
||
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
|
||
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
|
||
<WorkspaceAccountMenu
|
||
account={account}
|
||
onOpenProfile={onOpenProfile}
|
||
onOpenSettings={onOpenSettings}
|
||
onLogout={onLogout}
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="mailbox-stage">
|
||
<div className="mailbox-main-column">
|
||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||
<div className="mailbox-list-pane">
|
||
<div className="mailbox-list-bar" role="toolbar" aria-label="Mailbox controls">
|
||
<div className="mailbox-list-bar-actions">
|
||
<label className="mailbox-select-all">
|
||
<input
|
||
type="checkbox"
|
||
checked={allSelected}
|
||
ref={(node) => {
|
||
if (node) node.indeterminate = someSelected;
|
||
}}
|
||
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
|
||
onChange={toggleSelectAll}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className={status.loading ? "is-busy" : ""}
|
||
title="Refresh"
|
||
aria-label="Refresh"
|
||
onClick={load}
|
||
>
|
||
<RefreshCw size={16} strokeWidth={1.75} />
|
||
</button>
|
||
<div className="mailbox-page-size-wrap" onClick={(event) => event.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
title="More options"
|
||
aria-label="More options"
|
||
aria-expanded={pageMenuOpen}
|
||
onClick={() => setPageMenuOpen((open) => !open)}
|
||
>
|
||
<MoreHorizontal size={16} strokeWidth={1.75} />
|
||
</button>
|
||
{pageMenuOpen && (
|
||
<div className="mailbox-page-size-menu" role="menu">
|
||
<p>Emails per page</p>
|
||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||
<button
|
||
key={size}
|
||
type="button"
|
||
role="menuitemradio"
|
||
aria-checked={pageSize === size}
|
||
className={pageSize === size ? "active" : ""}
|
||
onClick={() => changePageSize(size)}
|
||
>
|
||
{size}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mailbox-list-bar-page">
|
||
<span className="mailbox-pagination-range">{rangeLabel}</span>
|
||
<button
|
||
type="button"
|
||
title="Newer"
|
||
aria-label="Previous page"
|
||
disabled={!cursorHistory.length || status.loading}
|
||
onClick={previousPage}
|
||
>
|
||
<ChevronLeft size={18} strokeWidth={1.75} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Older"
|
||
aria-label="Next page"
|
||
disabled={!nextCursor || status.loading}
|
||
onClick={nextPage}
|
||
>
|
||
<ChevronRight size={18} strokeWidth={1.75} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCount > 0 && (
|
||
<MailboxSelectionBar
|
||
count={selectedCount}
|
||
total={listedEmails.length}
|
||
hideSelectAll
|
||
onToggleAll={toggleSelectAll}
|
||
onArchive={() => runBulkAction("archive")}
|
||
onSpam={() => runBulkAction("spam")}
|
||
onDelete={() => runBulkAction("delete")}
|
||
onMarkUnread={() => runBulkAction("unread")}
|
||
onSnooze={() => runBulkAction("snooze")}
|
||
onMove={() => runBulkAction("move")}
|
||
onLabel={() => runBulkAction("label")}
|
||
onMore={() => runBulkAction("more")}
|
||
onSummarize={summarizeSelection}
|
||
/>
|
||
)}
|
||
|
||
{mailbox === "inbox" && selectedCount === 0 && (
|
||
<div className="mailbox-categories" role="tablist" aria-label="Inbox categories">
|
||
{CATEGORY_TABS.map(({ name, icon: Icon }) => (
|
||
<button
|
||
key={name}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={category === name}
|
||
className={category === name ? "active" : ""}
|
||
onClick={() => setCategory(name)}
|
||
>
|
||
<Icon size={16} strokeWidth={1.75} />
|
||
<span className="mailbox-category-label">{name}</span>
|
||
{name !== "All Mail" && counts[name] > 0 && (
|
||
<span className="mailbox-category-count">{counts[name]}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{mailbox !== "inbox" && (
|
||
<div className="mailbox-categories">
|
||
<button type="button" className="active">{title}</button>
|
||
</div>
|
||
)}
|
||
|
||
{filtersOpen && (
|
||
<div className="mailbox-filters">
|
||
<label>Read status
|
||
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
|
||
<option value="all">All messages</option>
|
||
<option value="unread">Unread</option>
|
||
<option value="read">Read</option>
|
||
</select>
|
||
</label>
|
||
<label>Sender
|
||
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
|
||
</label>
|
||
<label>From
|
||
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
|
||
</label>
|
||
<label>To
|
||
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<div className={`mailbox-rows${isEmpty ? " is-empty" : ""}${selectedCount > 0 ? " selecting" : ""}`} aria-busy={status.loading}>
|
||
{status.loading && <div className="mailbox-state loading">Loading messages…</div>}
|
||
{status.error && (
|
||
<div className="mailbox-state error" role="alert">
|
||
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
|
||
</div>
|
||
)}
|
||
{isEmpty && (
|
||
<MailboxEmptyState
|
||
mailbox={mailbox}
|
||
title={title}
|
||
category={mailbox === "inbox" ? category : null}
|
||
isBrandNew={isBrandNew}
|
||
hasFilters={hasFilters}
|
||
hasSearch={hasSearch}
|
||
lens={lens}
|
||
onCompose={onCompose}
|
||
onRefresh={load}
|
||
/>
|
||
)}
|
||
{!status.loading && !status.error && listedEmails.map((email) => (
|
||
<MailRow
|
||
key={email.id}
|
||
email={email}
|
||
selected={selected === email.id}
|
||
checked={Boolean(checked[email.id])}
|
||
onOpen={openEmail}
|
||
onToggleStar={toggleStar}
|
||
onToggleCheck={(item) => setChecked((current) => ({ ...current, [item.id]: !current[item.id] }))}
|
||
onTrash={removeEmail}
|
||
onMarkUnread={markUnread}
|
||
onArchive={(item) => hideForSession(item, "Archived in this view until you refresh.")}
|
||
onSnooze={(item) => hideForSession(item, "Snoozed in this view until you refresh.")}
|
||
onSummarize={summarize}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<MailDetail
|
||
email={detail}
|
||
thread={threadDetail}
|
||
messages={threadDetail?.messages}
|
||
loading={detailStatus.loading}
|
||
error={detailStatus.error}
|
||
busy={detailStatus.busy}
|
||
mailbox={mailbox}
|
||
viewerEmail={account?.email}
|
||
onClose={() => { setSelected(null); setDetail(null); setThreadDetail(null); }}
|
||
onToggleRead={toggleRead}
|
||
onToggleStar={(starred) => detail && toggleStar(detail, starred)}
|
||
onTrash={() => mutateSelected(trashEmail)}
|
||
onRestore={() => mutateSelected(restoreEmail)}
|
||
onDelete={deletePermanently}
|
||
onArchive={() => detail && hideForSession(detail, "Archived in this view until you refresh.")}
|
||
onSpam={() => detail && hideForSession(detail, "Marked as spam in this view until you refresh.")}
|
||
onSnooze={() => detail && hideForSession(detail, "Snoozed in this view until you refresh.")}
|
||
onMove={() => setAgentFocus("Move and labels — coming soon.")}
|
||
onReply={(replyAll = false) => {
|
||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||
onCompose?.(buildReplyContext(target, replyAll));
|
||
}}
|
||
onForward={() => {
|
||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||
onCompose?.({
|
||
subject: `Fwd: ${target?.subject || ""}`,
|
||
bodyHtml: buildReplyBodyHtml(target),
|
||
});
|
||
}}
|
||
onExtractActions={() => detail && extractActions(detail)}
|
||
onSummarizeThread={() => detail && summarizeThread(detail)}
|
||
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className={`mailbox-rail${sheet ? ` shell-${sheet}` : ""}`}>
|
||
{(sheet === "agent" || sheet === "calendar" || sheet === "notes") && (
|
||
<div className="shell-sheet-bar">
|
||
<button type="button" aria-label="Back" onClick={() => setSheet(null)}>
|
||
<ChevronLeft size={18} strokeWidth={1.75} />
|
||
</button>
|
||
<strong>{shellTitle(sheet)}</strong>
|
||
</div>
|
||
)}
|
||
<AgAgentPanel
|
||
account={account}
|
||
emails={listedEmails}
|
||
contextEmail={detail}
|
||
contextThread={threadDetail}
|
||
focus={agentFocus}
|
||
insight={insight}
|
||
loading={status.loading}
|
||
mailboxEmpty={isEmpty}
|
||
isBrandNewMailbox={isBrandNew && mailbox === "inbox"}
|
||
onCompose={onCompose}
|
||
onQuickAction={runQuickAction}
|
||
onOpenEmail={(emailId) => {
|
||
const email = listedEmails.find((item) => item.id === emailId);
|
||
if (email) openEmail(email);
|
||
}}
|
||
onAgentAsk={(query) => {
|
||
setAgentFocus("");
|
||
runInsight({
|
||
title: "Your question",
|
||
summary: query,
|
||
details: ["Scanning loaded messages for relevant context"],
|
||
actions: ["Summarize matching threads", "Draft a reply", "Find related emails"],
|
||
});
|
||
}}
|
||
/>
|
||
<StickyNotesPanel compact contextEmail={detail} />
|
||
</aside>
|
||
</div>
|
||
|
||
<CommandPalette
|
||
open={commandOpen}
|
||
onClose={() => setCommandOpen(false)}
|
||
onCommand={handleCommand}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|