diff --git a/frontend/src/components/ComposeDock.jsx b/frontend/src/components/ComposeDock.jsx new file mode 100644 index 0000000..2fe2292 --- /dev/null +++ b/frontend/src/components/ComposeDock.jsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ComposeModal from "./ComposeModal"; +import { clearDraft, draftHasContent, draftTitle, isDraftEmpty } from "../utils/composeDraft"; + +const AGENT_PANEL_WIDTH = 320; +const MAIL_GAP = 16; +const MAX_EXPANDED = 2; +const ICON_STROKE = 1.7; + +function useViewport() { + const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); + useEffect(() => { + function onResize() { + setSize({ width: window.innerWidth, height: window.innerHeight }); + } + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + return size; +} + +export default function ComposeDock({ account, onSent, agentOpen = true }) { + const [windows, setWindows] = useState([]); + const [overflowOpen, setOverflowOpen] = useState(false); + const focusRef = useRef(null); + const viewport = useViewport(); + + const agentReserve = agentOpen && viewport.width >= 960 ? AGENT_PANEL_WIDTH + MAIL_GAP : 0; + const dockRight = agentReserve + 16; + + const expandedWindows = useMemo( + () => windows.filter((window) => !window.minimized && !window.expanded), + [windows], + ); + const expandedLarge = useMemo( + () => windows.find((window) => window.expanded && !window.minimized), + [windows], + ); + const minimizedWindows = useMemo( + () => windows.filter((window) => window.minimized), + [windows], + ); + + const focusWindow = useCallback((windowId) => { + setWindows((current) => { + const target = current.find((window) => window.id === windowId); + if (!target) return current; + const others = current.filter((window) => window.id !== windowId); + return [{ ...target, focused: true, lastActive: Date.now() }, ...others]; + }); + focusRef.current = windowId; + }, []); + + const openCompose = useCallback(() => { + setWindows((current) => { + const blank = current.find((window) => !window.minimized && isDraftEmpty(window.id)); + if (blank) { + focusRef.current = blank.id; + return [ + { ...blank, focused: true, lastActive: Date.now() }, + ...current.filter((window) => window.id !== blank.id), + ]; + } + + const expanded = current.filter((window) => !window.minimized && !window.expanded); + let next = [...current]; + if (expanded.length >= MAX_EXPANDED) { + const candidate = [...expanded] + .sort((a, b) => (a.lastActive || 0) - (b.lastActive || 0)) + .find((window) => window.id !== focusRef.current); + if (candidate) { + next = next.map((window) => ( + window.id === candidate.id ? { ...window, minimized: true } : window + )); + } + } + + const id = `compose-${Date.now()}`; + focusRef.current = id; + return [{ id, minimized: false, expanded: false, focused: true, lastActive: Date.now() }, ...next]; + }); + }, []); + + useEffect(() => { + window.agmailOpenCompose = openCompose; + return () => { + delete window.agmailOpenCompose; + }; + }, [openCompose]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const popupId = params.get("compose"); + if (params.get("popup") === "compose" && popupId) { + setWindows([{ id: popupId, minimized: false, expanded: true, focused: true, lastActive: Date.now() }]); + } + }, []); + + function updateWindow(windowId, patch) { + setWindows((current) => current.map((window) => ( + window.id === windowId ? { ...window, ...patch, lastActive: Date.now() } : window + ))); + } + + function removeWindow(windowId) { + setWindows((current) => current.filter((window) => window.id !== windowId)); + } + + function handleClose(windowId) { + if (draftHasContent(windowId)) { + updateWindow(windowId, { minimized: true, expanded: false }); + return; + } + removeWindow(windowId); + } + + function handleDiscard(windowId) { + clearDraft(windowId); + removeWindow(windowId); + } + + function handleChipClose(windowId) { + if (draftHasContent(windowId) && !window.confirm("Discard this draft?")) return; + clearDraft(windowId); + removeWindow(windowId); + } + + function handleSent(windowId) { + removeWindow(windowId); + onSent?.(windowId); + } + + const visibleExpanded = expandedWindows.slice(0, MAX_EXPANDED); + const hiddenMinimized = minimizedWindows.slice(3); + const visibleChips = minimizedWindows.slice(0, 3); + + return ( + <> +
+ {visibleExpanded.length > 0 && ( +
+ {visibleExpanded.map((window) => ( + focusWindow(window.id)} + onClose={() => handleClose(window.id)} + onDiscard={() => handleDiscard(window.id)} + onSent={() => handleSent(window.id)} + onMinimize={() => updateWindow(window.id, { minimized: true, expanded: false })} + onExpand={() => updateWindow(window.id, { expanded: true })} + onRestore={() => updateWindow(window.id, { expanded: false })} + expanded={false} + minimized={false} + /> + ))} +
+ )} + + {visibleChips.length > 0 && ( +
+ {visibleChips.map((window) => ( + + ))} + {hiddenMinimized.length > 0 && ( +
+ + {overflowOpen && ( +
+ {hiddenMinimized.map((window) => ( + + ))} +
+ )} +
+ )} +
+ )} +
+ + {expandedLarge && ( + focusWindow(expandedLarge.id)} + onClose={() => handleClose(expandedLarge.id)} + onDiscard={() => handleDiscard(expandedLarge.id)} + onSent={() => handleSent(expandedLarge.id)} + onMinimize={() => updateWindow(expandedLarge.id, { minimized: true, expanded: false })} + onExpand={() => updateWindow(expandedLarge.id, { expanded: true })} + onRestore={() => updateWindow(expandedLarge.id, { expanded: false })} + expanded + minimized={false} + /> + )} + + ); +} + +export function triggerOpenCompose() { + window.agmailOpenCompose?.(); +} diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx index 5a8563d..cdc11cf 100644 --- a/frontend/src/components/ComposeModal.jsx +++ b/frontend/src/components/ComposeModal.jsx @@ -23,8 +23,7 @@ import { Trash2, Underline, X, - Maximize2, - Minimize2, + Square, ExternalLink, RemoveFormatting, IndentIncrease, @@ -34,9 +33,9 @@ import AgentSpark from "./AgentSpark"; import RecipientField, { recipientsToPayload } from "./compose/RecipientField"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listMailAliases, listOrgMembers, sendEmail } from "../api"; +import { clearDraft, draftStorageKey } from "../utils/composeDraft"; const EMOJI_SAMPLES = ["πŸ˜€", "😊", "πŸ‘", "πŸ™", "βœ…", "πŸŽ‰", "πŸ“Ž", "πŸ“…", "πŸ’‘", "πŸ”₯", "❀️", "πŸ˜…"]; -const DRAFT_KEY_PREFIX = "agmail_draft_"; function plainTextFromHtml(value) { const doc = new DOMParser().parseFromString(value, "text/html"); @@ -63,24 +62,17 @@ function detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }) { if (allRecipients.some((r) => r.invalid)) { warnings.push("One or more recipient addresses look invalid."); } - const mentionMatch = body.match(/\b(?:hi|hey|dear)\s+([A-Z][a-z]+)\b/); - if (mentionMatch) { - const name = mentionMatch[1].toLowerCase(); - const inRecipients = allRecipients.some((r) => r.name?.toLowerCase().includes(name)); - if (!inRecipients) { - warnings.push(`This email mentions ${mentionMatch[1]}, who isn't a recipient.`); - } - } - if (/(api[_-]?key|secret|password|token)\s*[:=]/i.test(body)) { - warnings.push("This message may contain sensitive credentials."); - } - if (orgDomain && allRecipients.filter((r) => r.external).length >= 10) { - warnings.push(`You're sending to ${allRecipients.length} recipients.`); - } return warnings; } -function MenuDropdown({ label, items, align = "left", className = "" }) { +function MenuDropdown({ + label, + items, + align = "left", + className = "", + showCaret = true, + iconStroke = 1.7, +}) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -96,7 +88,7 @@ function MenuDropdown({ label, items, align = "left", className = "" }) {
{open && (
@@ -123,23 +115,71 @@ function MenuDropdown({ label, items, align = "left", className = "" }) { ); } +function SendSplitButton({ onSend, onSchedule, disabled, iconStroke = 1.7 }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function close(event) { + if (!ref.current?.contains(event.target)) setOpen(false); + } + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); + + return ( +
+ + + {open && ( +
+ + +
+ )} +
+ ); +} + export default function ComposeModal({ account, windowId, - stackIndex = 0, + docked = true, + expanded = false, + minimized = false, + isFocused = true, + iconStroke = 1.7, onClose, + onDiscard, onSent, - onMinimizeChange, - initialMinimized = false, + onFocus, + onMinimize, + onExpand, + onRestore, }) { const editorRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const draftTimerRef = useRef(null); + const draftHydratedRef = useRef(false); - const [minimized, setMinimized] = useState(initialMinimized); - const [expanded, setExpanded] = useState(false); - const [detached, setDetached] = useState(false); const [showCc, setShowCc] = useState(false); const [showBcc, setShowBcc] = useState(false); const [to, setTo] = useState([]); @@ -158,8 +198,8 @@ export default function ComposeModal({ const [status, setStatus] = useState({ sending: false, error: "" }); const orgDomain = account.company_domain || account.email.split("@")[1]; - const draftKey = `${DRAFT_KEY_PREFIX}${windowId || account.id}`; - const draftHydratedRef = useRef(false); + const draftKey = draftStorageKey(windowId || account.id); + const bodyEmpty = !plainTextFromHtml(bodyHtml); const signatures = useMemo(() => [ { @@ -179,7 +219,7 @@ export default function ComposeModal({ const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() }; const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length; if (empty) { - localStorage.removeItem(draftKey); + clearDraft(windowId || account.id); setDraftStatus(""); return; } @@ -189,7 +229,7 @@ export default function ComposeModal({ setDraftStatus("Saved to Drafts"); window.setTimeout(() => setDraftStatus(""), 2400); }, 420); - }, [to, cc, bcc, subject, bodyHtml, draftKey]); + }, [to, cc, bcc, subject, bodyHtml, draftKey, windowId, account.id]); useEffect(() => { if (draftHydratedRef.current) return; @@ -255,11 +295,6 @@ export default function ComposeModal({ setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain })); }, [bodyHtml, to, cc, bcc, attachments, orgDomain]); - useEffect(() => { - onMinimizeChange?.(minimized); - }, [minimized, onMinimizeChange]); - - function flashNotice(message) { setNotice(message); window.setTimeout(() => setNotice(""), 2800); @@ -337,16 +372,10 @@ export default function ComposeModal({ } function discardDraft() { - localStorage.removeItem(draftKey); - setTo([]); - setCc([]); - setBcc([]); - setSubject(""); - setBodyHtml(""); - if (editorRef.current) editorRef.current.innerHTML = ""; - setAttachments([]); - setDraftStatus(""); - onClose(); + const hasContent = subject || bodyHtml || to.length || cc.length || bcc.length || attachments.length; + if (hasContent && !window.confirm("Discard this draft?")) return; + clearDraft(windowId || account.id); + onDiscard?.(); } const submitSend = useCallback(async () => { @@ -367,17 +396,18 @@ export default function ComposeModal({ }, sessionStorage.getItem("agmail_access_token"), ); - localStorage.removeItem(draftKey); + clearDraft(windowId || account.id); flashNotice("Message sent. Undo available for 5 seconds."); - onSent(); + onSent?.(); } catch (error) { setStatus({ sending: false, error: error.message }); } - }, [to, cc, bcc, subject, bodyHtml, draftKey, onSent]); + }, [to, cc, bcc, subject, bodyHtml, windowId, account.id, onSent]); useEffect(() => { function onKeyDown(event) { const mod = event.metaKey || event.ctrlKey; + if (!isFocused) return; if (mod && event.key === "Enter" && !status.sending) { event.preventDefault(); submitSend(); @@ -394,47 +424,44 @@ export default function ComposeModal({ event.preventDefault(); setShowBcc(true); } - if (event.key === "Escape" && !status.sending) { - onClose(); - } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [status.sending, submitSend, onClose]); + }, [status.sending, submitSend, isFocused]); - function togglePopOut() { - if (detached) { - setDetached(false); - return; - } - setDetached(true); - setExpanded(false); - flashNotice("Composer popped out β€” still linked to your mailbox."); + function popOut() { + const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`; + window.open( + url, + `agmail-compose-${windowId}`, + "width=900,height=780,menubar=no,toolbar=no,location=no,resizable=yes", + ); + onMinimize?.(); } const shellClass = [ "compose-window", - minimized ? "minimized" : "", + docked ? "docked" : "", expanded ? "expanded" : "", - detached ? "detached" : "", + isFocused ? "focused" : "", ].filter(Boolean).join(" "); - const offset = stackIndex * 28; - return (
onFocus?.()} >
-
+

New message

From: {fromAddress.email} @@ -451,55 +478,72 @@ export default function ComposeModal({
- - -
- {!minimized && ( -
-
+
+
+ To +
-
- {!showCc && ( - - )} - {!showBcc && ( - - )} +
+
+ {!showCc && } + {!showBcc && } +
+
+ + {showCc && ( +
+ Cc +
+
+ )} + {showBcc && ( +
+ Bcc +
+ +
+
+ )} - {showCc && ( - - )} - {showBcc && ( - - )} - -
+
+
- {attachments.length > 0 && ( -
- {attachments.map((file) => ( -
- {file.inline ? "πŸ–Ό" : "πŸ“„"} -
- {file.name} - {file.type || "File"} Β· {formatFileSize(file.size)} -
-
- - - -
+ {attachments.length > 0 && ( +
+ {attachments.map((file) => ( +
+ {file.inline ? "πŸ–Ό" : "πŸ“„"} +
+ {file.name} + {file.type || "File"} Β· {formatFileSize(file.size)}
- ))} -
- )} +
+ + + +
+
+ ))} +
+ )} -
event.preventDefault()} - onDrop={(event) => { - event.preventDefault(); - addFiles(event.dataTransfer.files); - }} - /> +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + addFiles(event.dataTransfer.files); + }} + /> +
- Ask AG Agent to write or improve this email… - + + {bodyEmpty + ? "Describe the email you want to write…" + : "Ask AG Agent to improve this draft…"} + +
- - - - +
+ + + + +
- - - +
+ + + +
- - - - - - - +
+ + +
+ +
+ + + + + +
{warnings.length > 0 && ( @@ -589,36 +650,24 @@ export default function ComposeModal({
- Send} - items={[ - { - id: "now", - label: "Send now", - icon: , - onClick: submitSend, - }, - { - id: "schedule", - label: "Schedule send", - icon: , - onClick: () => flashNotice("Scheduled send β€” coming soon"), - }, - ]} + flashNotice("Scheduled send β€” coming soon")} + disabled={status.sending} + iconStroke={iconStroke} />
{showEmoji && (
@@ -630,7 +679,9 @@ export default function ComposeModal({
} + showCaret={false} + iconStroke={iconStroke} + label={} items={signatures.map((signature) => ({ id: signature.id, label: signature.id === "none" ? signature.label : `Insert ${signature.label}`, @@ -639,7 +690,9 @@ export default function ComposeModal({ /> } + showCaret={false} + iconStroke={iconStroke} + label={} items={[ { id: "standard", label: "Standard", active: true, onClick: () => flashNotice("Standard security") }, { id: "confidential", label: "Confidential", onClick: () => flashNotice("Confidential mode β€” coming soon") }, @@ -648,7 +701,9 @@ export default function ComposeModal({ /> } + showCaret={false} + iconStroke={iconStroke} + label={} items={[ { id: "receipt", label: "Request read receipt", onClick: () => flashNotice("Read receipt β€” coming soon") }, { id: "priority", label: "Mark as important", onClick: () => flashNotice("Priority β€” coming soon") }, @@ -656,41 +711,41 @@ export default function ComposeModal({ { id: "html", label: "Edit HTML source", onClick: () => flashNotice("HTML editor β€” coming soon") }, { id: "print", label: "Print", onClick: () => window.print() }, { id: "spell", label: "Check spelling", onClick: () => flashNotice("Spell check β€” coming soon") }, - { id: "fullscreen", label: "Default to full screen", onClick: () => setExpanded(true) }, + { id: "fullscreen", label: "Default to full screen", onClick: () => onExpand?.() }, ]} />
{draftStatus && {draftStatus}}
- - { - addFiles(event.target.files); - event.target.value = ""; - }} - /> - { - addFiles(event.target.files, true); - event.target.value = ""; - }} - />
- )} + + { + addFiles(event.target.files); + event.target.value = ""; + }} + /> + { + addFiles(event.target.files, true); + event.target.value = ""; + }} + /> +
); } diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index f9f0bd9..a2659f6 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -37,7 +37,7 @@ import CompanySecurity from "./CompanySecurity"; import CompanyOverview from "./CompanyOverview"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import ChangePasswordModal from "./ChangePasswordModal"; -import ComposeModal from "./ComposeModal"; +import ComposeDock, { triggerOpenCompose } from "./ComposeDock"; import MailboxView from "./MailboxView"; const mailItems = [ @@ -107,7 +107,6 @@ export default function DashboardPage() { const [domainError, setDomainError] = useState(""); const [active, setActive] = useState("Inbox"); const [menuOpen, setMenuOpen] = useState(false); - const [composeWindows, setComposeWindows] = useState([]); const [notice, setNotice] = useState(""); const [loading, setLoading] = useState(true); const [mailRefreshKey, setMailRefreshKey] = useState(0); @@ -206,15 +205,10 @@ export default function DashboardPage() { } function openCompose() { - setComposeWindows((windows) => [...windows, { id: `compose-${Date.now()}-${windows.length}` }]); + triggerOpenCompose(); } - function closeCompose(windowId) { - setComposeWindows((windows) => windows.filter((window) => window.id !== windowId)); - } - - function emailSent(windowId) { - closeCompose(windowId); + function emailSent() { setActive("Outbox"); setMailRefreshKey((key) => key + 1); setNotice("Message accepted by the mail server. Delivery can take a few minutes β€” check spam if it does not arrive."); @@ -515,16 +509,7 @@ export default function DashboardPage() { }} /> )} - {composeWindows.map((window, index) => ( - closeCompose(window.id)} - onSent={() => emailSent(window.id)} - /> - ))} +
); } diff --git a/frontend/src/components/compose/RecipientField.jsx b/frontend/src/components/compose/RecipientField.jsx index df2fd28..0a3d02e 100644 --- a/frontend/src/components/compose/RecipientField.jsx +++ b/frontend/src/components/compose/RecipientField.jsx @@ -34,6 +34,7 @@ export default function RecipientField({ orgDomain, onFocus, autoFocus = false, + inline = false, }) { const [input, setInput] = useState(""); const [open, setOpen] = useState(false); @@ -134,8 +135,8 @@ export default function RecipientField({ } return ( -
- {label} +
+ {label ? {label} : null}
inputRef.current?.focus()}> {recipients.map((recipient, index) => ( { if (input.trim()) commitInput(); }} - placeholder={recipients.length ? "" : "Recipients"} + placeholder={recipients.length ? "" : (inline ? "" : "Recipients")} autoComplete="off" spellCheck={false} />
{open && filtered.length > 0 && ( -
    +
      {filtered.map((item, index) => (