email compose

This commit is contained in:
debu-2 2026-10-05 21:57:22 +05:30
parent 2af0724938
commit f95901d596
6 changed files with 639 additions and 274 deletions

View file

@ -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 (
<>
<div className="compose-dock" style={{ "--compose-dock-right": `${dockRight}px` }}>
{visibleExpanded.length > 0 && (
<div className="compose-dock-expanded">
{visibleExpanded.map((window) => (
<ComposeModal
key={window.id}
account={account}
windowId={window.id}
docked
isFocused={window.id === focusRef.current}
iconStroke={ICON_STROKE}
onFocus={() => 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}
/>
))}
</div>
)}
{visibleChips.length > 0 && (
<div className="compose-dock-chips">
{visibleChips.map((window) => (
<button
key={window.id}
type="button"
className="compose-draft-chip"
onClick={() => updateWindow(window.id, { minimized: false })}
>
<span>{draftTitle(window.id)}</span>
<span
className="compose-draft-chip-close"
role="button"
tabIndex={0}
aria-label="Close draft"
onClick={(event) => {
event.stopPropagation();
handleChipClose(window.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.stopPropagation();
handleChipClose(window.id);
}
}}
>
×
</span>
</button>
))}
{hiddenMinimized.length > 0 && (
<div className="compose-draft-overflow">
<button type="button" className="compose-draft-chip" onClick={() => setOverflowOpen((v) => !v)}>
+{hiddenMinimized.length}
</button>
{overflowOpen && (
<div className="compose-draft-overflow-menu">
{hiddenMinimized.map((window) => (
<button
key={window.id}
type="button"
onClick={() => {
updateWindow(window.id, { minimized: false });
setOverflowOpen(false);
}}
>
{draftTitle(window.id)}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{expandedLarge && (
<ComposeModal
account={account}
windowId={expandedLarge.id}
docked={false}
isFocused
iconStroke={ICON_STROKE}
onFocus={() => 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?.();
}

View file

@ -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 = "" }) {
<div className={`compose-menu ${className}`} ref={ref}>
<button type="button" className="compose-menu-trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
{label}
<ChevronDown size={14} />
{showCaret && <ChevronDown size={14} strokeWidth={iconStroke} />}
</button>
{open && (
<div className={`compose-menu-panel align-${align}`} role="menu">
@ -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 (
<div className="compose-send-split" ref={ref}>
<button type="button" className="compose-send-primary" onClick={onSend} disabled={disabled}>
<Send size={14} strokeWidth={iconStroke} />
Send
</button>
<button
type="button"
className="compose-send-caret"
aria-label="Send options"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
>
<ChevronDown size={14} strokeWidth={iconStroke} />
</button>
{open && (
<div className="compose-send-menu-panel" role="menu">
<button type="button" role="menuitem" onClick={() => { onSend(); setOpen(false); }}>
<Send size={14} strokeWidth={iconStroke} />
Send now
</button>
<button type="button" role="menuitem" onClick={() => { onSchedule(); setOpen(false); }}>
<Clock size={14} strokeWidth={iconStroke} />
Schedule send
</button>
</div>
)}
</div>
);
}
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 (
<div
className={shellClass}
style={{ right: `${24 + offset}px`, bottom: `${24 + offset}px` }}
role="dialog"
aria-label="Compose message"
onMouseDown={() => onFocus?.()}
>
<header className="compose-window-header">
<div className="compose-window-title">
<span className="compose-agent-mark"><AgentSpark /></span>
<div>
<div className="compose-window-heading">
<h2>New message</h2>
<MenuDropdown
className="compose-from-picker"
showCaret={false}
iconStroke={iconStroke}
label={
<span className="compose-from-label">
From: <strong>{fromAddress.email}</strong>
@ -451,55 +478,72 @@ export default function ComposeModal({
</div>
</div>
<div className="compose-window-controls">
<button type="button" title="Minimize" aria-label="Minimize" onClick={() => setMinimized((v) => !v)}>
<Minus size={16} />
<button type="button" title="Minimize — save as draft chip" aria-label="Minimize" onClick={onMinimize}>
<Minus size={14} strokeWidth={iconStroke} />
</button>
<button
type="button"
title={expanded ? "Restore" : "Expand"}
title={expanded ? "Restore floating composer" : "Expand — larger in-app composer"}
aria-label={expanded ? "Restore" : "Expand"}
onClick={() => { setExpanded((v) => !v); setDetached(false); }}
onClick={() => (expanded ? onRestore?.() : onExpand?.())}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
<Square size={13} strokeWidth={iconStroke} />
</button>
<button type="button" title="Pop out" aria-label="Pop out" onClick={togglePopOut}>
<ExternalLink size={15} />
<button type="button" title="Pop out — separate browser window" aria-label="Pop out" onClick={popOut}>
<ExternalLink size={14} strokeWidth={iconStroke} />
</button>
<button type="button" title="Close" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
<X size={17} />
<button
type="button"
title="Close — save draft if not empty"
aria-label="Close compose"
onClick={onClose}
disabled={status.sending}
>
<X size={14} strokeWidth={iconStroke} />
</button>
</div>
</header>
{!minimized && (
<div className="compose-window-body">
<div className="compose-recipient-block">
<div className="compose-window-body">
<div className="compose-row compose-recipient-row">
<span className="compose-row-label">To</span>
<div className="compose-row-main">
<RecipientField
label="To"
label=""
recipients={to}
onChange={setTo}
suggestions={contacts}
orgDomain={orgDomain}
inline
autoFocus
/>
<div className="compose-recipient-toggles">
{!showCc && (
<button type="button" onClick={() => setShowCc(true)}>Cc</button>
)}
{!showBcc && (
<button type="button" onClick={() => setShowBcc(true)}>Bcc</button>
)}
</div>
<div className="compose-recipient-toggles">
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
</div>
</div>
{showCc && (
<div className="compose-row compose-recipient-row">
<span className="compose-row-label">Cc</span>
<div className="compose-row-main">
<RecipientField label="" recipients={cc} onChange={setCc} suggestions={contacts} orgDomain={orgDomain} inline />
</div>
</div>
)}
{showBcc && (
<div className="compose-row compose-recipient-row">
<span className="compose-row-label">Bcc</span>
<div className="compose-row-main">
<RecipientField label="" recipients={bcc} onChange={setBcc} suggestions={contacts} orgDomain={orgDomain} inline />
</div>
</div>
)}
{showCc && (
<RecipientField label="Cc" recipients={cc} onChange={setCc} suggestions={contacts} orgDomain={orgDomain} />
)}
{showBcc && (
<RecipientField label="Bcc" recipients={bcc} onChange={setBcc} suggestions={contacts} orgDomain={orgDomain} />
)}
<div className="compose-subject-row">
<div className="compose-row compose-subject-row">
<span className="compose-row-label" aria-hidden="true" />
<div className="compose-row-main compose-subject-main">
<input
className="compose-subject-input"
value={subject}
@ -513,67 +557,84 @@ export default function ComposeModal({
</button>
)}
</div>
</div>
{attachments.length > 0 && (
<div className="compose-attachments" aria-label="Attachments">
{attachments.map((file) => (
<div key={file.id} className="compose-attachment-card">
<span className="compose-attachment-icon">{file.inline ? "🖼" : "📄"}</span>
<div>
<strong>{file.name}</strong>
<small>{file.type || "File"} · {formatFileSize(file.size)}</small>
</div>
<div className="compose-attachment-actions">
<button type="button" onClick={() => flashNotice("Attachment preview — coming soon")}>Preview</button>
<button type="button" onClick={() => flashNotice("Summarize attachment — coming soon")}>Summarize</button>
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeAttachment(file.id)}>
<X size={14} />
</button>
</div>
{attachments.length > 0 && (
<div className="compose-attachments" aria-label="Attachments">
{attachments.map((file) => (
<div key={file.id} className="compose-attachment-card">
<span className="compose-attachment-icon">{file.inline ? "🖼" : "📄"}</span>
<div>
<strong>{file.name}</strong>
<small>{file.type || "File"} · {formatFileSize(file.size)}</small>
</div>
))}
</div>
)}
<div className="compose-attachment-actions">
<button type="button" onClick={() => flashNotice("Attachment preview — coming soon")}>Preview</button>
<button type="button" onClick={() => flashNotice("Summarize attachment — coming soon")}>Summarize</button>
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeAttachment(file.id)}>
<X size={14} strokeWidth={iconStroke} />
</button>
</div>
</div>
))}
</div>
)}
<div
className="compose-editor"
ref={editorRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-placeholder="Write your message…"
onInput={syncBodyFromEditor}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
/>
<div
className="compose-editor"
ref={editorRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-placeholder="Write your message…"
onInput={syncBodyFromEditor}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
/>
<div className="compose-bottom-stack">
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
<AgentSpark />
<span>Ask AG Agent to write or improve this email…</span>
<button type="button" disabled aria-hidden="true"><ArrowRight size={16} /></button>
<span>
{bodyEmpty
? "Describe the email you want to write…"
: "Ask AG Agent to improve this draft…"}
</span>
<button type="button" disabled aria-hidden="true">
<ArrowRight size={14} strokeWidth={iconStroke} className={bodyEmpty ? "compose-agent-arrow" : "compose-agent-arrow active"} />
</button>
</div>
<div className="compose-format-bar" aria-label="Formatting">
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={15} /></button>
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={15} /></button>
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={15} /></button>
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={15} /></button>
<div className="compose-format-group">
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></button>
</div>
<span className="compose-format-divider" />
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={15} /></button>
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={15} /></button>
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={15} /></button>
<div className="compose-format-group compose-format-align">
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></button>
</div>
<span className="compose-format-divider" />
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={15} /></button>
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={15} /></button>
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={15} /></button>
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={15} /></button>
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={15} /></button>
<button type="button" title="Link" onClick={insertLink}><Link size={15} /></button>
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={15} /></button>
<div className="compose-format-group">
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button>
</div>
<span className="compose-format-divider compose-format-divider-advanced" />
<div className="compose-format-group compose-format-advanced">
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Link" onClick={insertLink}><Link size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></button>
</div>
</div>
{warnings.length > 0 && (
@ -589,36 +650,24 @@ export default function ComposeModal({
<footer className="compose-action-bar">
<div className="compose-action-left">
<MenuDropdown
className="compose-send-menu"
label={<><Send size={15} /> Send</>}
items={[
{
id: "now",
label: "Send now",
icon: <Send size={14} />,
onClick: submitSend,
},
{
id: "schedule",
label: "Schedule send",
icon: <Clock size={14} />,
onClick: () => flashNotice("Scheduled send — coming soon"),
},
]}
<SendSplitButton
onSend={submitSend}
onSchedule={() => flashNotice("Scheduled send — coming soon")}
disabled={status.sending}
iconStroke={iconStroke}
/>
<button type="button" className="compose-icon-btn" title="Attach files" onClick={() => fileInputRef.current?.click()}>
<Paperclip size={17} />
<Paperclip size={16} strokeWidth={iconStroke} />
</button>
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
<Image size={17} />
<Image size={16} strokeWidth={iconStroke} />
</button>
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
<Link size={17} />
<Link size={16} strokeWidth={iconStroke} />
</button>
<div className="compose-emoji-wrap">
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
<Smile size={17} />
<Smile size={16} strokeWidth={iconStroke} />
</button>
{showEmoji && (
<div className="compose-emoji-panel">
@ -630,7 +679,9 @@ export default function ComposeModal({
</div>
<MenuDropdown
className="compose-icon-menu"
label={<PenLine size={17} />}
showCaret={false}
iconStroke={iconStroke}
label={<PenLine size={16} strokeWidth={iconStroke} />}
items={signatures.map((signature) => ({
id: signature.id,
label: signature.id === "none" ? signature.label : `Insert ${signature.label}`,
@ -639,7 +690,9 @@ export default function ComposeModal({
/>
<MenuDropdown
className="compose-icon-menu"
label={<Lock size={17} />}
showCaret={false}
iconStroke={iconStroke}
label={<Lock size={16} strokeWidth={iconStroke} />}
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({
/>
<MenuDropdown
className="compose-icon-menu"
label={<MoreHorizontal size={17} />}
showCaret={false}
iconStroke={iconStroke}
label={<MoreHorizontal size={16} strokeWidth={iconStroke} />}
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?.() },
]}
/>
</div>
<div className="compose-action-right">
{draftStatus && <span className="compose-draft-status">{draftStatus}</span>}
<button type="button" className="compose-discard" title="Discard draft" onClick={discardDraft}>
<Trash2 size={16} />
<Trash2 size={15} strokeWidth={iconStroke} />
</button>
</div>
</footer>
<input
ref={fileInputRef}
type="file"
multiple
hidden
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(event) => {
addFiles(event.target.files, true);
event.target.value = "";
}}
/>
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
hidden
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(event) => {
addFiles(event.target.files, true);
event.target.value = "";
}}
/>
</div>
</div>
);
}

View file

@ -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) => (
<ComposeModal
key={window.id}
windowId={window.id}
stackIndex={index}
account={account}
onClose={() => closeCompose(window.id)}
onSent={() => emailSent(window.id)}
/>
))}
<ComposeDock account={account} onSent={emailSent} agentOpen={!companyActive} />
</div>
);
}

View file

@ -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 (
<div className="compose-recipient-field" ref={wrapRef}>
<span className="compose-recipient-label">{label}</span>
<div className={`compose-recipient-field${inline ? " inline" : ""}`} ref={wrapRef}>
{label ? <span className="compose-recipient-label">{label}</span> : null}
<div className="compose-recipient-chips" onClick={() => inputRef.current?.focus()}>
{recipients.map((recipient, index) => (
<span
@ -169,13 +170,13 @@ export default function RecipientField({
onBlur={() => {
if (input.trim()) commitInput();
}}
placeholder={recipients.length ? "" : "Recipients"}
placeholder={recipients.length ? "" : (inline ? "" : "Recipients")}
autoComplete="off"
spellCheck={false}
/>
</div>
{open && filtered.length > 0 && (
<ul className="compose-recipient-suggestions" role="listbox">
<ul className="compose-recipient-suggestions" role="listbox" style={inline ? { left: 0 } : undefined}>
{filtered.map((item, index) => (
<li key={item.email || item.name} role="option" aria-selected={index === highlight}>
<button