email compose
This commit is contained in:
parent
2af0724938
commit
f95901d596
6 changed files with 639 additions and 274 deletions
245
frontend/src/components/ComposeDock.jsx
Normal file
245
frontend/src/components/ComposeDock.jsx
Normal 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?.();
|
||||||
|
}
|
||||||
|
|
@ -23,8 +23,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Underline,
|
Underline,
|
||||||
X,
|
X,
|
||||||
Maximize2,
|
Square,
|
||||||
Minimize2,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
RemoveFormatting,
|
RemoveFormatting,
|
||||||
IndentIncrease,
|
IndentIncrease,
|
||||||
|
|
@ -34,9 +33,9 @@ import AgentSpark from "./AgentSpark";
|
||||||
import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
|
import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { listMailAliases, listOrgMembers, sendEmail } from "../api";
|
import { listMailAliases, listOrgMembers, sendEmail } from "../api";
|
||||||
|
import { clearDraft, draftStorageKey } from "../utils/composeDraft";
|
||||||
|
|
||||||
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"];
|
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"];
|
||||||
const DRAFT_KEY_PREFIX = "agmail_draft_";
|
|
||||||
|
|
||||||
function plainTextFromHtml(value) {
|
function plainTextFromHtml(value) {
|
||||||
const doc = new DOMParser().parseFromString(value, "text/html");
|
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)) {
|
if (allRecipients.some((r) => r.invalid)) {
|
||||||
warnings.push("One or more recipient addresses look 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;
|
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 [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
|
@ -96,7 +88,7 @@ function MenuDropdown({ label, items, align = "left", className = "" }) {
|
||||||
<div className={`compose-menu ${className}`} ref={ref}>
|
<div className={`compose-menu ${className}`} ref={ref}>
|
||||||
<button type="button" className="compose-menu-trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
<button type="button" className="compose-menu-trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
||||||
{label}
|
{label}
|
||||||
<ChevronDown size={14} />
|
{showCaret && <ChevronDown size={14} strokeWidth={iconStroke} />}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className={`compose-menu-panel align-${align}`} role="menu">
|
<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({
|
export default function ComposeModal({
|
||||||
account,
|
account,
|
||||||
windowId,
|
windowId,
|
||||||
stackIndex = 0,
|
docked = true,
|
||||||
|
expanded = false,
|
||||||
|
minimized = false,
|
||||||
|
isFocused = true,
|
||||||
|
iconStroke = 1.7,
|
||||||
onClose,
|
onClose,
|
||||||
|
onDiscard,
|
||||||
onSent,
|
onSent,
|
||||||
onMinimizeChange,
|
onFocus,
|
||||||
initialMinimized = false,
|
onMinimize,
|
||||||
|
onExpand,
|
||||||
|
onRestore,
|
||||||
}) {
|
}) {
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const imageInputRef = useRef(null);
|
const imageInputRef = useRef(null);
|
||||||
const draftTimerRef = 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 [showCc, setShowCc] = useState(false);
|
||||||
const [showBcc, setShowBcc] = useState(false);
|
const [showBcc, setShowBcc] = useState(false);
|
||||||
const [to, setTo] = useState([]);
|
const [to, setTo] = useState([]);
|
||||||
|
|
@ -158,8 +198,8 @@ export default function ComposeModal({
|
||||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||||
|
|
||||||
const orgDomain = account.company_domain || account.email.split("@")[1];
|
const orgDomain = account.company_domain || account.email.split("@")[1];
|
||||||
const draftKey = `${DRAFT_KEY_PREFIX}${windowId || account.id}`;
|
const draftKey = draftStorageKey(windowId || account.id);
|
||||||
const draftHydratedRef = useRef(false);
|
const bodyEmpty = !plainTextFromHtml(bodyHtml);
|
||||||
|
|
||||||
const signatures = useMemo(() => [
|
const signatures = useMemo(() => [
|
||||||
{
|
{
|
||||||
|
|
@ -179,7 +219,7 @@ export default function ComposeModal({
|
||||||
const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() };
|
const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() };
|
||||||
const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length;
|
const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length;
|
||||||
if (empty) {
|
if (empty) {
|
||||||
localStorage.removeItem(draftKey);
|
clearDraft(windowId || account.id);
|
||||||
setDraftStatus("");
|
setDraftStatus("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +229,7 @@ export default function ComposeModal({
|
||||||
setDraftStatus("Saved to Drafts");
|
setDraftStatus("Saved to Drafts");
|
||||||
window.setTimeout(() => setDraftStatus(""), 2400);
|
window.setTimeout(() => setDraftStatus(""), 2400);
|
||||||
}, 420);
|
}, 420);
|
||||||
}, [to, cc, bcc, subject, bodyHtml, draftKey]);
|
}, [to, cc, bcc, subject, bodyHtml, draftKey, windowId, account.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draftHydratedRef.current) return;
|
if (draftHydratedRef.current) return;
|
||||||
|
|
@ -255,11 +295,6 @@ export default function ComposeModal({
|
||||||
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
|
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
|
||||||
}, [bodyHtml, to, cc, bcc, attachments, orgDomain]);
|
}, [bodyHtml, to, cc, bcc, attachments, orgDomain]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onMinimizeChange?.(minimized);
|
|
||||||
}, [minimized, onMinimizeChange]);
|
|
||||||
|
|
||||||
|
|
||||||
function flashNotice(message) {
|
function flashNotice(message) {
|
||||||
setNotice(message);
|
setNotice(message);
|
||||||
window.setTimeout(() => setNotice(""), 2800);
|
window.setTimeout(() => setNotice(""), 2800);
|
||||||
|
|
@ -337,16 +372,10 @@ export default function ComposeModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardDraft() {
|
function discardDraft() {
|
||||||
localStorage.removeItem(draftKey);
|
const hasContent = subject || bodyHtml || to.length || cc.length || bcc.length || attachments.length;
|
||||||
setTo([]);
|
if (hasContent && !window.confirm("Discard this draft?")) return;
|
||||||
setCc([]);
|
clearDraft(windowId || account.id);
|
||||||
setBcc([]);
|
onDiscard?.();
|
||||||
setSubject("");
|
|
||||||
setBodyHtml("");
|
|
||||||
if (editorRef.current) editorRef.current.innerHTML = "";
|
|
||||||
setAttachments([]);
|
|
||||||
setDraftStatus("");
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitSend = useCallback(async () => {
|
const submitSend = useCallback(async () => {
|
||||||
|
|
@ -367,17 +396,18 @@ export default function ComposeModal({
|
||||||
},
|
},
|
||||||
sessionStorage.getItem("agmail_access_token"),
|
sessionStorage.getItem("agmail_access_token"),
|
||||||
);
|
);
|
||||||
localStorage.removeItem(draftKey);
|
clearDraft(windowId || account.id);
|
||||||
flashNotice("Message sent. Undo available for 5 seconds.");
|
flashNotice("Message sent. Undo available for 5 seconds.");
|
||||||
onSent();
|
onSent?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus({ sending: false, error: error.message });
|
setStatus({ sending: false, error: error.message });
|
||||||
}
|
}
|
||||||
}, [to, cc, bcc, subject, bodyHtml, draftKey, onSent]);
|
}, [to, cc, bcc, subject, bodyHtml, windowId, account.id, onSent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
const mod = event.metaKey || event.ctrlKey;
|
||||||
|
if (!isFocused) return;
|
||||||
if (mod && event.key === "Enter" && !status.sending) {
|
if (mod && event.key === "Enter" && !status.sending) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submitSend();
|
submitSend();
|
||||||
|
|
@ -394,47 +424,44 @@ export default function ComposeModal({
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setShowBcc(true);
|
setShowBcc(true);
|
||||||
}
|
}
|
||||||
if (event.key === "Escape" && !status.sending) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [status.sending, submitSend, onClose]);
|
}, [status.sending, submitSend, isFocused]);
|
||||||
|
|
||||||
function togglePopOut() {
|
function popOut() {
|
||||||
if (detached) {
|
const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`;
|
||||||
setDetached(false);
|
window.open(
|
||||||
return;
|
url,
|
||||||
}
|
`agmail-compose-${windowId}`,
|
||||||
setDetached(true);
|
"width=900,height=780,menubar=no,toolbar=no,location=no,resizable=yes",
|
||||||
setExpanded(false);
|
);
|
||||||
flashNotice("Composer popped out — still linked to your mailbox.");
|
onMinimize?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellClass = [
|
const shellClass = [
|
||||||
"compose-window",
|
"compose-window",
|
||||||
minimized ? "minimized" : "",
|
docked ? "docked" : "",
|
||||||
expanded ? "expanded" : "",
|
expanded ? "expanded" : "",
|
||||||
detached ? "detached" : "",
|
isFocused ? "focused" : "",
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
const offset = stackIndex * 28;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={shellClass}
|
className={shellClass}
|
||||||
style={{ right: `${24 + offset}px`, bottom: `${24 + offset}px` }}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Compose message"
|
aria-label="Compose message"
|
||||||
|
onMouseDown={() => onFocus?.()}
|
||||||
>
|
>
|
||||||
<header className="compose-window-header">
|
<header className="compose-window-header">
|
||||||
<div className="compose-window-title">
|
<div className="compose-window-title">
|
||||||
<span className="compose-agent-mark"><AgentSpark /></span>
|
<span className="compose-agent-mark"><AgentSpark /></span>
|
||||||
<div>
|
<div className="compose-window-heading">
|
||||||
<h2>New message</h2>
|
<h2>New message</h2>
|
||||||
<MenuDropdown
|
<MenuDropdown
|
||||||
className="compose-from-picker"
|
className="compose-from-picker"
|
||||||
|
showCaret={false}
|
||||||
|
iconStroke={iconStroke}
|
||||||
label={
|
label={
|
||||||
<span className="compose-from-label">
|
<span className="compose-from-label">
|
||||||
From: <strong>{fromAddress.email}</strong>
|
From: <strong>{fromAddress.email}</strong>
|
||||||
|
|
@ -451,55 +478,72 @@ export default function ComposeModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="compose-window-controls">
|
<div className="compose-window-controls">
|
||||||
<button type="button" title="Minimize" aria-label="Minimize" onClick={() => setMinimized((v) => !v)}>
|
<button type="button" title="Minimize — save as draft chip" aria-label="Minimize" onClick={onMinimize}>
|
||||||
<Minus size={16} />
|
<Minus size={14} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={expanded ? "Restore" : "Expand"}
|
title={expanded ? "Restore floating composer" : "Expand — larger in-app composer"}
|
||||||
aria-label={expanded ? "Restore" : "Expand"}
|
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>
|
||||||
<button type="button" title="Pop out" aria-label="Pop out" onClick={togglePopOut}>
|
<button type="button" title="Pop out — separate browser window" aria-label="Pop out" onClick={popOut}>
|
||||||
<ExternalLink size={15} />
|
<ExternalLink size={14} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Close" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
|
<button
|
||||||
<X size={17} />
|
type="button"
|
||||||
|
title="Close — save draft if not empty"
|
||||||
|
aria-label="Close compose"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={status.sending}
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!minimized && (
|
|
||||||
<div className="compose-window-body">
|
<div className="compose-window-body">
|
||||||
<div className="compose-recipient-block">
|
<div className="compose-row compose-recipient-row">
|
||||||
|
<span className="compose-row-label">To</span>
|
||||||
|
<div className="compose-row-main">
|
||||||
<RecipientField
|
<RecipientField
|
||||||
label="To"
|
label=""
|
||||||
recipients={to}
|
recipients={to}
|
||||||
onChange={setTo}
|
onChange={setTo}
|
||||||
suggestions={contacts}
|
suggestions={contacts}
|
||||||
orgDomain={orgDomain}
|
orgDomain={orgDomain}
|
||||||
|
inline
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="compose-recipient-toggles">
|
<div className="compose-recipient-toggles">
|
||||||
{!showCc && (
|
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||||||
<button type="button" onClick={() => setShowCc(true)}>Cc</button>
|
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
|
||||||
)}
|
|
||||||
{!showBcc && (
|
|
||||||
<button type="button" onClick={() => setShowBcc(true)}>Bcc</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCc && (
|
{showCc && (
|
||||||
<RecipientField label="Cc" recipients={cc} onChange={setCc} suggestions={contacts} orgDomain={orgDomain} />
|
<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 && (
|
{showBcc && (
|
||||||
<RecipientField label="Bcc" recipients={bcc} onChange={setBcc} suggestions={contacts} orgDomain={orgDomain} />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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
|
<input
|
||||||
className="compose-subject-input"
|
className="compose-subject-input"
|
||||||
value={subject}
|
value={subject}
|
||||||
|
|
@ -513,6 +557,7 @@ export default function ComposeModal({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="compose-attachments" aria-label="Attachments">
|
<div className="compose-attachments" aria-label="Attachments">
|
||||||
|
|
@ -527,7 +572,7 @@ export default function ComposeModal({
|
||||||
<button type="button" onClick={() => flashNotice("Attachment preview — coming soon")}>Preview</button>
|
<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" onClick={() => flashNotice("Summarize attachment — coming soon")}>Summarize</button>
|
||||||
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeAttachment(file.id)}>
|
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeAttachment(file.id)}>
|
||||||
<X size={14} />
|
<X size={14} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -551,29 +596,45 @@ export default function ComposeModal({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="compose-bottom-stack">
|
||||||
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
|
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
|
||||||
<AgentSpark />
|
<AgentSpark />
|
||||||
<span>Ask AG Agent to write or improve this email…</span>
|
<span>
|
||||||
<button type="button" disabled aria-hidden="true"><ArrowRight size={16} /></button>
|
{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>
|
||||||
|
|
||||||
<div className="compose-format-bar" aria-label="Formatting">
|
<div className="compose-format-bar" aria-label="Formatting">
|
||||||
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={15} /></button>
|
<div className="compose-format-group">
|
||||||
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={15} /></button>
|
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button>
|
||||||
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={15} /></button>
|
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button>
|
||||||
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={15} /></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" />
|
<span className="compose-format-divider" />
|
||||||
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={15} /></button>
|
<div className="compose-format-group compose-format-align">
|
||||||
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={15} /></button>
|
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button>
|
||||||
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={15} /></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" />
|
<span className="compose-format-divider" />
|
||||||
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={15} /></button>
|
<div className="compose-format-group">
|
||||||
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={15} /></button>
|
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button>
|
||||||
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={15} /></button>
|
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button>
|
||||||
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={15} /></button>
|
</div>
|
||||||
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={15} /></button>
|
<span className="compose-format-divider compose-format-divider-advanced" />
|
||||||
<button type="button" title="Link" onClick={insertLink}><Link size={15} /></button>
|
<div className="compose-format-group compose-format-advanced">
|
||||||
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={15} /></button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
|
|
@ -589,36 +650,24 @@ export default function ComposeModal({
|
||||||
|
|
||||||
<footer className="compose-action-bar">
|
<footer className="compose-action-bar">
|
||||||
<div className="compose-action-left">
|
<div className="compose-action-left">
|
||||||
<MenuDropdown
|
<SendSplitButton
|
||||||
className="compose-send-menu"
|
onSend={submitSend}
|
||||||
label={<><Send size={15} /> Send</>}
|
onSchedule={() => flashNotice("Scheduled send — coming soon")}
|
||||||
items={[
|
disabled={status.sending}
|
||||||
{
|
iconStroke={iconStroke}
|
||||||
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"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<button type="button" className="compose-icon-btn" title="Attach files" onClick={() => fileInputRef.current?.click()}>
|
<button type="button" className="compose-icon-btn" title="Attach files" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Paperclip size={17} />
|
<Paperclip size={16} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
|
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
|
||||||
<Image size={17} />
|
<Image size={16} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
|
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
|
||||||
<Link size={17} />
|
<Link size={16} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
<div className="compose-emoji-wrap">
|
<div className="compose-emoji-wrap">
|
||||||
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
|
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
|
||||||
<Smile size={17} />
|
<Smile size={16} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
{showEmoji && (
|
{showEmoji && (
|
||||||
<div className="compose-emoji-panel">
|
<div className="compose-emoji-panel">
|
||||||
|
|
@ -630,7 +679,9 @@ export default function ComposeModal({
|
||||||
</div>
|
</div>
|
||||||
<MenuDropdown
|
<MenuDropdown
|
||||||
className="compose-icon-menu"
|
className="compose-icon-menu"
|
||||||
label={<PenLine size={17} />}
|
showCaret={false}
|
||||||
|
iconStroke={iconStroke}
|
||||||
|
label={<PenLine size={16} strokeWidth={iconStroke} />}
|
||||||
items={signatures.map((signature) => ({
|
items={signatures.map((signature) => ({
|
||||||
id: signature.id,
|
id: signature.id,
|
||||||
label: signature.id === "none" ? signature.label : `Insert ${signature.label}`,
|
label: signature.id === "none" ? signature.label : `Insert ${signature.label}`,
|
||||||
|
|
@ -639,7 +690,9 @@ export default function ComposeModal({
|
||||||
/>
|
/>
|
||||||
<MenuDropdown
|
<MenuDropdown
|
||||||
className="compose-icon-menu"
|
className="compose-icon-menu"
|
||||||
label={<Lock size={17} />}
|
showCaret={false}
|
||||||
|
iconStroke={iconStroke}
|
||||||
|
label={<Lock size={16} strokeWidth={iconStroke} />}
|
||||||
items={[
|
items={[
|
||||||
{ id: "standard", label: "Standard", active: true, onClick: () => flashNotice("Standard security") },
|
{ id: "standard", label: "Standard", active: true, onClick: () => flashNotice("Standard security") },
|
||||||
{ id: "confidential", label: "Confidential", onClick: () => flashNotice("Confidential mode — coming soon") },
|
{ id: "confidential", label: "Confidential", onClick: () => flashNotice("Confidential mode — coming soon") },
|
||||||
|
|
@ -648,7 +701,9 @@ export default function ComposeModal({
|
||||||
/>
|
/>
|
||||||
<MenuDropdown
|
<MenuDropdown
|
||||||
className="compose-icon-menu"
|
className="compose-icon-menu"
|
||||||
label={<MoreHorizontal size={17} />}
|
showCaret={false}
|
||||||
|
iconStroke={iconStroke}
|
||||||
|
label={<MoreHorizontal size={16} strokeWidth={iconStroke} />}
|
||||||
items={[
|
items={[
|
||||||
{ id: "receipt", label: "Request read receipt", onClick: () => flashNotice("Read receipt — coming soon") },
|
{ id: "receipt", label: "Request read receipt", onClick: () => flashNotice("Read receipt — coming soon") },
|
||||||
{ id: "priority", label: "Mark as important", onClick: () => flashNotice("Priority — coming soon") },
|
{ id: "priority", label: "Mark as important", onClick: () => flashNotice("Priority — coming soon") },
|
||||||
|
|
@ -656,17 +711,18 @@ export default function ComposeModal({
|
||||||
{ id: "html", label: "Edit HTML source", onClick: () => flashNotice("HTML editor — coming soon") },
|
{ id: "html", label: "Edit HTML source", onClick: () => flashNotice("HTML editor — coming soon") },
|
||||||
{ id: "print", label: "Print", onClick: () => window.print() },
|
{ id: "print", label: "Print", onClick: () => window.print() },
|
||||||
{ id: "spell", label: "Check spelling", onClick: () => flashNotice("Spell check — coming soon") },
|
{ 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>
|
||||||
<div className="compose-action-right">
|
<div className="compose-action-right">
|
||||||
{draftStatus && <span className="compose-draft-status">{draftStatus}</span>}
|
{draftStatus && <span className="compose-draft-status">{draftStatus}</span>}
|
||||||
<button type="button" className="compose-discard" title="Discard draft" onClick={discardDraft}>
|
<button type="button" className="compose-discard" title="Discard draft" onClick={discardDraft}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={15} strokeWidth={iconStroke} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -690,7 +746,6 @@ export default function ComposeModal({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import CompanySecurity from "./CompanySecurity";
|
||||||
import CompanyOverview from "./CompanyOverview";
|
import CompanyOverview from "./CompanyOverview";
|
||||||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||||
import ChangePasswordModal from "./ChangePasswordModal";
|
import ChangePasswordModal from "./ChangePasswordModal";
|
||||||
import ComposeModal from "./ComposeModal";
|
import ComposeDock, { triggerOpenCompose } from "./ComposeDock";
|
||||||
import MailboxView from "./MailboxView";
|
import MailboxView from "./MailboxView";
|
||||||
|
|
||||||
const mailItems = [
|
const mailItems = [
|
||||||
|
|
@ -107,7 +107,6 @@ export default function DashboardPage() {
|
||||||
const [domainError, setDomainError] = useState("");
|
const [domainError, setDomainError] = useState("");
|
||||||
const [active, setActive] = useState("Inbox");
|
const [active, setActive] = useState("Inbox");
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [composeWindows, setComposeWindows] = useState([]);
|
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
||||||
|
|
@ -206,15 +205,10 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompose() {
|
function openCompose() {
|
||||||
setComposeWindows((windows) => [...windows, { id: `compose-${Date.now()}-${windows.length}` }]);
|
triggerOpenCompose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCompose(windowId) {
|
function emailSent() {
|
||||||
setComposeWindows((windows) => windows.filter((window) => window.id !== windowId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailSent(windowId) {
|
|
||||||
closeCompose(windowId);
|
|
||||||
setActive("Outbox");
|
setActive("Outbox");
|
||||||
setMailRefreshKey((key) => key + 1);
|
setMailRefreshKey((key) => key + 1);
|
||||||
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
|
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) => (
|
<ComposeDock account={account} onSent={emailSent} agentOpen={!companyActive} />
|
||||||
<ComposeModal
|
|
||||||
key={window.id}
|
|
||||||
windowId={window.id}
|
|
||||||
stackIndex={index}
|
|
||||||
account={account}
|
|
||||||
onClose={() => closeCompose(window.id)}
|
|
||||||
onSent={() => emailSent(window.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export default function RecipientField({
|
||||||
orgDomain,
|
orgDomain,
|
||||||
onFocus,
|
onFocus,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
inline = false,
|
||||||
}) {
|
}) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -134,8 +135,8 @@ export default function RecipientField({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="compose-recipient-field" ref={wrapRef}>
|
<div className={`compose-recipient-field${inline ? " inline" : ""}`} ref={wrapRef}>
|
||||||
<span className="compose-recipient-label">{label}</span>
|
{label ? <span className="compose-recipient-label">{label}</span> : null}
|
||||||
<div className="compose-recipient-chips" onClick={() => inputRef.current?.focus()}>
|
<div className="compose-recipient-chips" onClick={() => inputRef.current?.focus()}>
|
||||||
{recipients.map((recipient, index) => (
|
{recipients.map((recipient, index) => (
|
||||||
<span
|
<span
|
||||||
|
|
@ -169,13 +170,13 @@ export default function RecipientField({
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (input.trim()) commitInput();
|
if (input.trim()) commitInput();
|
||||||
}}
|
}}
|
||||||
placeholder={recipients.length ? "" : "Recipients"}
|
placeholder={recipients.length ? "" : (inline ? "" : "Recipients")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{open && filtered.length > 0 && (
|
{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) => (
|
{filtered.map((item, index) => (
|
||||||
<li key={item.email || item.name} role="option" aria-selected={index === highlight}>
|
<li key={item.email || item.name} role="option" aria-selected={index === highlight}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -670,29 +670,45 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.mail-detail-meta time { white-space:nowrap; }
|
.mail-detail-meta time { white-space:nowrap; }
|
||||||
.mail-detail-body { padding-top:23px; color:#383440; font-size:12px; line-height:1.75; white-space:pre-wrap; overflow-wrap:anywhere; }
|
.mail-detail-body { padding-top:23px; color:#383440; font-size:12px; line-height:1.75; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
|
|
||||||
.compose-window { position:fixed; z-index:40; width:min(560px,calc(100vw - 48px)); max-height:min(78vh,720px); display:flex; flex-direction:column; overflow:hidden; border:1px solid var(--border-normal); border-radius:14px 14px 4px 4px; background:#fff; box-shadow:0 12px 40px rgba(8,8,33,.18),0 2px 8px rgba(8,8,33,.08); animation:composeSlideUp var(--motion-slow) var(--ease-standard); }
|
.compose-dock { position:fixed; right:var(--compose-dock-right,16px); bottom:16px; z-index:40; display:flex; flex-direction:column; align-items:flex-end; gap:8px; pointer-events:none; }
|
||||||
.compose-window.minimized { width:min(360px,calc(100vw - 48px)); max-height:none; }
|
.compose-dock-expanded,.compose-dock-chips { display:flex; flex-direction:row-reverse; align-items:flex-end; gap:12px; pointer-events:auto; }
|
||||||
.compose-window.expanded { width:min(900px,calc(100vw - 48px)); max-height:min(88vh,820px); }
|
.compose-draft-chip { max-width:220px; height:34px; padding:0 10px 0 12px; display:inline-flex; align-items:center; gap:8px; border:1px solid var(--border-normal); border-radius:9px; background:#fff; box-shadow:0 6px 20px rgba(8,8,33,.12); color:var(--ink); font-size:11px; font-weight:600; }
|
||||||
.compose-window.detached { width:min(760px,calc(100vw - 80px)); max-height:min(90vh,860px); right:40px !important; bottom:40px !important; box-shadow:0 20px 60px rgba(8,8,33,.22); }
|
.compose-draft-chip span:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
@keyframes composeSlideUp { from { opacity:0; transform:translateY(16px); } to { opacity:1; transform:translateY(0); } }
|
.compose-draft-chip-close { width:18px; height:18px; display:grid; place-items:center; border-radius:5px; color:#8a848d; font-size:14px; line-height:1; }
|
||||||
.compose-window-header { min-height:54px; padding:0 10px 0 14px; display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff6f2,#fffefd); cursor:default; }
|
.compose-draft-chip-close:hover { background:#f3efec; color:var(--ink); }
|
||||||
.compose-window-title { min-width:0; display:flex; align-items:center; gap:10px; }
|
.compose-draft-overflow { position:relative; }
|
||||||
.compose-window-title h2 { margin:0; font:600 14px "Manrope"; letter-spacing:-.3px; white-space:nowrap; }
|
.compose-draft-overflow-menu { position:absolute; right:0; bottom:calc(100% + 6px); min-width:180px; padding:6px; display:grid; gap:2px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
||||||
.compose-from-picker .compose-menu-trigger { min-height:auto; padding:0; border:0; background:transparent; color:#8a848d; font-size:10px; font-weight:500; box-shadow:none; }
|
.compose-draft-overflow-menu button { width:100%; min-height:32px; padding:0 10px; border:0; border-radius:8px; background:transparent; text-align:left; font-size:11px; font-weight:600; }
|
||||||
.compose-from-label strong { color:#5c5760; font-weight:600; }
|
.compose-draft-overflow-menu button:hover { background:#f7f4f1; }
|
||||||
.compose-window-controls { display:flex; align-items:center; gap:2px; flex-shrink:0; }
|
.compose-window { --compose-pad:15px; --compose-label:30px; width:580px; min-width:520px; max-width:680px; height:520px; display:flex; flex-direction:column; overflow:hidden; border:1px solid var(--border-normal); border-radius:12px 12px 4px 4px; background:#fff; box-shadow:0 10px 32px rgba(8,8,33,.16); animation:composeSlideUp var(--motion-slow) var(--ease-standard); pointer-events:auto; resize:both; }
|
||||||
.compose-window-controls button { width:30px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#77717a; }
|
.compose-window.docked { position:relative; right:auto; bottom:auto; }
|
||||||
|
.compose-window.expanded { position:fixed; left:50%; top:50%; right:auto !important; bottom:auto !important; transform:translate(-50%,-50%); width:min(900px,calc(100vw - 80px)); min-width:min(800px,calc(100vw - 80px)); max-width:960px; height:min(78vh,760px); z-index:50; resize:none; }
|
||||||
|
.compose-window.focused { box-shadow:0 14px 40px rgba(8,8,33,.2); }
|
||||||
|
@keyframes composeSlideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
.compose-window.expanded { animation:composeExpandIn var(--motion-slow) var(--ease-standard); }
|
||||||
|
@keyframes composeExpandIn { from { opacity:0; transform:translate(-50%,-48%); } to { opacity:1; transform:translate(-50%,-50%); } }
|
||||||
|
.compose-window-header { height:52px; padding:0 15px; display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid var(--line); background:#fffefd; flex-shrink:0; }
|
||||||
|
.compose-window-title { min-width:0; display:flex; align-items:center; gap:9px; }
|
||||||
|
.compose-window-heading { min-width:0; display:grid; gap:2px; }
|
||||||
|
.compose-window-heading h2 { margin:0; font:650 14px/1.1 "Manrope"; letter-spacing:-.25px; white-space:nowrap; }
|
||||||
|
.compose-from-picker .compose-menu-trigger { min-height:auto; height:auto; padding:0; border:0; background:transparent; color:#8a848d; font:400 10px/1.2 "DM Sans"; box-shadow:none; }
|
||||||
|
.compose-from-label strong { color:#5c5760; font-weight:500; }
|
||||||
|
.compose-window-controls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||||
|
.compose-window-controls button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#6f6a73; }
|
||||||
.compose-window-controls button:hover { background:#f4eeea; color:var(--ink); }
|
.compose-window-controls button:hover { background:#f4eeea; color:var(--ink); }
|
||||||
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:auto; }
|
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:hidden; }
|
||||||
.compose-recipient-block { position:relative; display:grid; grid-template-columns:1fr auto; align-items:start; border-bottom:1px solid var(--line); }
|
.compose-row { min-height:46px; padding:0 var(--compose-pad); display:grid; grid-template-columns:var(--compose-label) minmax(0,1fr) auto; align-items:center; gap:12px; border-bottom:1px solid var(--line); flex-shrink:0; }
|
||||||
.compose-recipient-toggles { padding:14px 12px 0 0; display:flex; gap:6px; }
|
.compose-row-label { color:#77717a; font-size:11px; font-weight:700; }
|
||||||
.compose-recipient-toggles button { padding:4px 7px; border:0; border-radius:6px; background:transparent; color:#847e87; font-size:10px; font-weight:700; }
|
.compose-row-main { min-width:0; }
|
||||||
.compose-recipient-toggles button:hover { background:var(--blush); color:#bc5948; }
|
.compose-recipient-row .compose-row-main { display:flex; align-items:center; min-height:34px; }
|
||||||
.compose-recipient-field { position:relative; min-height:46px; padding:6px 12px 6px 0; display:grid; grid-template-columns:34px 1fr; align-items:start; gap:4px; border-bottom:1px solid var(--line); }
|
.compose-recipient-toggles { display:flex; align-items:center; gap:12px; align-self:center; }
|
||||||
.compose-recipient-label { padding-top:10px; color:#77717a; font-size:11px; font-weight:700; }
|
.compose-recipient-toggles button { padding:0; border:0; background:transparent; color:#847e87; font-size:10px; font-weight:700; line-height:1; }
|
||||||
.compose-recipient-chips { min-height:34px; padding:4px 0; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
.compose-recipient-toggles button:hover { color:#bc5948; }
|
||||||
.compose-recipient-input { flex:1; min-width:120px; height:28px; border:0; outline:0; background:transparent; color:var(--ink); font:500 12px "DM Sans"; }
|
.compose-recipient-field { position:relative; width:100%; }
|
||||||
.compose-chip { max-width:100%; height:26px; padding:0 4px 0 8px; display:inline-flex; align-items:center; gap:4px; border-radius:7px; background:#f3efec; color:var(--ink); font-size:11px; font-weight:600; }
|
.compose-recipient-field.inline { display:block; }
|
||||||
|
.compose-recipient-chips { min-height:28px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||||
|
.compose-recipient-input { flex:1; min-width:80px; height:28px; border:0; outline:0; background:transparent; color:var(--ink); font:500 12px/28px "DM Sans"; }
|
||||||
|
.compose-chip { max-width:100%; height:24px; padding:0 4px 0 8px; display:inline-flex; align-items:center; gap:4px; border-radius:6px; background:#f3efec; color:var(--ink); font-size:11px; font-weight:600; }
|
||||||
.compose-chip.external { background:#fff4ea; color:#9a5b28; }
|
.compose-chip.external { background:#fff4ea; color:#9a5b28; }
|
||||||
.compose-chip.invalid { background:#fdeeed; color:#b54f3d; }
|
.compose-chip.invalid { background:#fdeeed; color:#b54f3d; }
|
||||||
.compose-chip.group { background:#eef4ff; color:#4d74c4; }
|
.compose-chip.group { background:#eef4ff; color:#4d74c4; }
|
||||||
|
|
@ -700,48 +716,62 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.compose-chip-label em { padding:1px 5px; border-radius:4px; background:rgba(0,0,0,.06); font-size:8px; font-weight:700; font-style:normal; letter-spacing:.3px; text-transform:uppercase; }
|
.compose-chip-label em { padding:1px 5px; border-radius:4px; background:rgba(0,0,0,.06); font-size:8px; font-weight:700; font-style:normal; letter-spacing:.3px; text-transform:uppercase; }
|
||||||
.compose-chip button { width:18px; height:18px; display:grid; place-items:center; border:0; border-radius:5px; background:transparent; color:#8a848d; }
|
.compose-chip button { width:18px; height:18px; display:grid; place-items:center; border:0; border-radius:5px; background:transparent; color:#8a848d; }
|
||||||
.compose-chip button:hover { background:rgba(0,0,0,.06); color:var(--ink); }
|
.compose-chip button:hover { background:rgba(0,0,0,.06); color:var(--ink); }
|
||||||
.compose-recipient-suggestions { position:absolute; top:calc(100% + 2px); left:34px; right:12px; z-index:5; margin:0; padding:6px; list-style:none; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
.compose-recipient-suggestions { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:8; margin:0; padding:6px; list-style:none; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
||||||
.compose-recipient-suggestions button { width:100%; padding:8px 10px; display:grid; grid-template-columns:1fr auto; gap:2px 8px; align-items:center; border:0; border-radius:8px; background:transparent; text-align:left; color:var(--ink); }
|
.compose-recipient-suggestions button { width:100%; padding:8px 10px; display:grid; grid-template-columns:1fr auto; gap:2px 8px; align-items:center; border:0; border-radius:8px; background:transparent; text-align:left; color:var(--ink); }
|
||||||
.compose-recipient-suggestions button strong { font-size:12px; font-weight:600; }
|
.compose-recipient-suggestions button strong { font-size:12px; font-weight:600; }
|
||||||
.compose-recipient-suggestions button small { grid-column:1; color:#8a848d; font-size:10px; }
|
.compose-recipient-suggestions button small { grid-column:1; color:#8a848d; font-size:10px; }
|
||||||
.compose-recipient-suggestions button em { font-size:8px; font-weight:700; font-style:normal; letter-spacing:.3px; text-transform:uppercase; color:#9a5b28; }
|
.compose-recipient-suggestions button em { font-size:8px; font-weight:700; font-style:normal; letter-spacing:.3px; text-transform:uppercase; color:#9a5b28; }
|
||||||
.compose-recipient-suggestions button.active,.compose-recipient-suggestions button:hover { background:#f7f4f1; }
|
.compose-recipient-suggestions button.active,.compose-recipient-suggestions button:hover { background:#f7f4f1; }
|
||||||
.compose-subject-row { min-height:46px; padding:0 12px; display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--line); }
|
.compose-subject-row { grid-template-columns:var(--compose-label) minmax(0,1fr); }
|
||||||
.compose-subject-input { flex:1; min-width:0; height:44px; border:0; outline:0; background:transparent; color:var(--ink); font:500 13px "DM Sans"; }
|
.compose-subject-main { display:flex; align-items:center; gap:8px; min-width:0; }
|
||||||
.compose-subject-suggest { flex-shrink:0; height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:5px; border:1px solid var(--agent-border); border-radius:7px; background:var(--agent-soft); color:#6d4aff; font-size:10px; font-weight:700; }
|
.compose-subject-input { width:100%; min-width:0; height:28px; border:0; outline:0; background:transparent; color:var(--ink); font:500 13px/28px "DM Sans"; }
|
||||||
.compose-subject-suggest:hover { background:#ebe4ff; }
|
.compose-subject-suggest { flex-shrink:0; height:26px; padding:0 8px; display:inline-flex; align-items:center; gap:4px; border:1px solid #E5DFFB; border-radius:7px; background:#FAF8FF; color:#7357F6; font-size:10px; font-weight:700; }
|
||||||
.compose-attachments { padding:10px 12px 4px; display:flex; flex-wrap:wrap; gap:8px; border-bottom:1px solid var(--line); }
|
.compose-subject-suggest:hover { background:#F3F0FF; }
|
||||||
.compose-attachment-card { min-width:min(100%,220px); max-width:280px; padding:8px 10px; display:grid; grid-template-columns:auto 1fr auto; gap:8px; align-items:center; border:1px solid var(--line); border-radius:10px; background:#fcfbfa; }
|
.compose-attachments { padding:8px var(--compose-pad) 4px; display:flex; flex-wrap:wrap; gap:8px; border-bottom:1px solid var(--line); flex-shrink:0; }
|
||||||
.compose-attachment-icon { font-size:18px; line-height:1; }
|
.compose-attachment-card { min-width:min(100%,200px); max-width:260px; padding:7px 9px; display:grid; grid-template-columns:auto 1fr auto; gap:8px; align-items:center; border:1px solid var(--line); border-radius:9px; background:#fcfbfa; }
|
||||||
|
.compose-attachment-icon { font-size:16px; line-height:1; }
|
||||||
.compose-attachment-card strong { display:block; font-size:11px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.compose-attachment-card strong { display:block; font-size:11px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.compose-attachment-card small { display:block; margin-top:2px; color:#8a848d; font-size:9px; }
|
.compose-attachment-card small { display:block; margin-top:1px; color:#8a848d; font-size:9px; }
|
||||||
.compose-attachment-actions { display:flex; align-items:center; gap:2px; }
|
.compose-attachment-actions { display:flex; align-items:center; gap:2px; }
|
||||||
.compose-attachment-actions button { padding:4px 5px; border:0; border-radius:6px; background:transparent; color:#8a848d; font-size:8px; font-weight:700; }
|
.compose-attachment-actions button { padding:3px 4px; border:0; border-radius:5px; background:transparent; color:#8a848d; font-size:8px; font-weight:700; }
|
||||||
.compose-attachment-actions button:hover { background:#f3efec; color:var(--ink); }
|
.compose-attachment-actions button:hover { background:#f3efec; color:var(--ink); }
|
||||||
.compose-editor { min-height:180px; max-height:38vh; flex:1; padding:14px 16px; overflow:auto; outline:0; color:var(--ink); font:400 13px/1.65 "DM Sans"; }
|
.compose-editor { min-height:180px; flex:1; padding:12px var(--compose-pad); overflow:auto; outline:0; color:var(--ink); font:400 13px/1.6 "DM Sans"; }
|
||||||
.compose-editor:empty:before { content:attr(data-placeholder); color:#aaa4ac; pointer-events:none; }
|
.compose-editor:empty:before { content:attr(data-placeholder); color:#aaa4ac; pointer-events:none; }
|
||||||
.compose-editor blockquote { margin:8px 0; padding-left:12px; border-left:3px solid #e5dfd9; color:#67677a; }
|
.compose-editor blockquote { margin:8px 0; padding-left:12px; border-left:3px solid #e5dfd9; color:#67677a; }
|
||||||
.compose-agent-bar { min-height:44px; margin:0; padding:0 12px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); background:linear-gradient(100deg,#f8f6ff,#fff8f5); color:#7a6f92; font-size:11px; }
|
.compose-bottom-stack { flex-shrink:0; border-top:1px solid var(--line); }
|
||||||
.compose-agent-bar-disabled { opacity:.72; }
|
.compose-agent-bar { height:44px; padding:0 var(--compose-pad); display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border-bottom:1px solid #EEE9FF; background:#FAF8FF; color:#77728C; font-size:11px; }
|
||||||
.compose-agent-bar button { width:30px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:var(--ink); color:#fff; opacity:.35; }
|
.compose-agent-bar .agent-spark { color:#7357F6; }
|
||||||
.compose-format-bar { padding:6px 10px; display:flex; flex-wrap:wrap; align-items:center; gap:2px; border-bottom:1px solid var(--line); background:#fcfbfa; }
|
.compose-agent-bar-disabled { pointer-events:none; }
|
||||||
.compose-format-bar button { width:30px; height:28px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#77717a; }
|
.compose-agent-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:7px; background:#ECE7FF; color:#B7AEE6; }
|
||||||
.compose-format-bar button:hover { background:#fff0eb; color:#c25c49; }
|
.compose-agent-arrow { color:#B7AEE6; }
|
||||||
.compose-format-divider { width:1px; height:18px; margin:0 4px; background:#ebe5e1; }
|
.compose-agent-arrow.active { color:#7357F6; }
|
||||||
.compose-warnings { padding:8px 12px; border-bottom:1px solid #f0e2c8; background:#fff8ea; }
|
.compose-format-bar { height:40px; padding:0 var(--compose-pad); display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); background:#fff; }
|
||||||
.compose-warnings p { margin:0; color:#9a7228; font-size:10px; line-height:1.45; }
|
.compose-format-group { display:flex; align-items:center; gap:1px; }
|
||||||
.compose-warnings p+p { margin-top:4px; }
|
.compose-format-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; }
|
||||||
.compose-error { margin:0; padding:8px 12px; color:#b54f3d; font-size:10px; background:#fdeeed; }
|
.compose-format-bar button:hover { background:#f7f4f1; color:#c25c49; }
|
||||||
.compose-notice { margin:0; padding:8px 12px; color:#4d74c4; font-size:10px; background:#eef4ff; }
|
.compose-format-divider { width:1px; height:16px; margin:0 5px; background:#ebe5e1; flex-shrink:0; }
|
||||||
.compose-action-bar { min-height:48px; padding:8px 10px 10px; display:flex; align-items:center; justify-content:space-between; gap:10px; background:#fff; }
|
.compose-warnings { padding:6px var(--compose-pad); border-bottom:1px solid #f0e2c8; background:#fff8ea; }
|
||||||
.compose-action-left { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
|
.compose-warnings p { margin:0; color:#9a7228; font-size:10px; line-height:1.4; }
|
||||||
|
.compose-warnings p+p { margin-top:3px; }
|
||||||
|
.compose-error { margin:0; padding:6px var(--compose-pad); color:#b54f3d; font-size:10px; background:#fdeeed; }
|
||||||
|
.compose-notice { margin:0; padding:6px var(--compose-pad); color:#4d74c4; font-size:10px; background:#eef4ff; }
|
||||||
|
.compose-action-bar { height:52px; padding:0 var(--compose-pad); display:flex; align-items:center; justify-content:space-between; gap:10px; background:#fff; }
|
||||||
|
.compose-action-left { display:flex; align-items:center; gap:2px; min-width:0; flex-wrap:nowrap; overflow:hidden; }
|
||||||
.compose-action-right { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
.compose-action-right { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||||
|
.compose-send-split { position:relative; height:36px; display:inline-flex; align-items:stretch; border-radius:8px; overflow:hidden; box-shadow:0 4px 12px rgba(255,95,80,.18); flex-shrink:0; }
|
||||||
|
.compose-send-primary { min-width:94px; height:36px; padding:0 12px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; }
|
||||||
|
.compose-send-primary:hover:not(:disabled) { filter:brightness(1.03); }
|
||||||
|
.compose-send-primary:disabled { opacity:.6; cursor:wait; }
|
||||||
|
.compose-send-caret { width:30px; height:36px; display:grid; place-items:center; border:0; border-left:1px solid rgba(8,8,33,.08); background:#ff7f67; color:var(--ink); }
|
||||||
|
.compose-send-caret:hover:not(:disabled) { background:#ff7560; }
|
||||||
|
.compose-send-menu-panel { position:absolute; left:0; bottom:calc(100% + 6px); z-index:8; min-width:170px; padding:6px; display:grid; gap:2px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
||||||
|
.compose-send-menu-panel button { width:100%; min-height:34px; padding:0 10px; display:flex; align-items:center; gap:8px; border:0; border-radius:8px; background:transparent; font-size:11px; font-weight:600; text-align:left; }
|
||||||
|
.compose-send-menu-panel button:hover { background:#f7f4f1; }
|
||||||
.compose-menu { position:relative; }
|
.compose-menu { position:relative; }
|
||||||
.compose-menu-trigger { min-height:34px; padding:0 12px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; box-shadow:var(--shadow-accent); }
|
.compose-menu-trigger { min-height:34px; padding:0 12px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:var(--coral); color:var(--ink); font-size:12px; font-weight:700; box-shadow:var(--shadow-accent); }
|
||||||
.compose-send-menu .compose-menu-trigger { padding:0 14px; }
|
.compose-icon-menu .compose-menu-trigger,.compose-icon-btn { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#6f6a73; box-shadow:none; }
|
||||||
.compose-icon-menu .compose-menu-trigger,.compose-icon-btn { width:34px; height:34px; padding:0; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#6f6a73; box-shadow:none; }
|
|
||||||
.compose-icon-menu .compose-menu-trigger:hover,.compose-icon-btn:hover { background:#f3efec; color:var(--ink); }
|
.compose-icon-menu .compose-menu-trigger:hover,.compose-icon-btn:hover { background:#f3efec; color:var(--ink); }
|
||||||
.compose-menu-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:6; min-width:190px; padding:6px; display:grid; gap:2px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
.compose-menu-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; min-width:190px; padding:6px; display:grid; gap:2px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
||||||
.compose-menu-panel.align-right { left:auto; right:0; }
|
.compose-menu-panel.align-right { left:auto; right:0; }
|
||||||
.compose-menu-panel button { width:100%; min-height:34px; padding:0 10px; display:flex; align-items:center; gap:8px; border:0; border-radius:8px; background:transparent; color:var(--ink); font-size:11px; font-weight:600; text-align:left; }
|
.compose-menu-panel button { width:100%; min-height:34px; padding:0 10px; display:flex; align-items:center; gap:8px; border:0; border-radius:8px; background:transparent; color:var(--ink); font-size:11px; font-weight:600; text-align:left; }
|
||||||
.compose-menu-panel button small { margin-left:auto; color:#8a848d; font-size:9px; font-weight:500; }
|
.compose-menu-panel button small { margin-left:auto; color:#8a848d; font-size:9px; font-weight:500; }
|
||||||
|
|
@ -749,12 +779,16 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.compose-menu-panel button.active { background:#eef8f2; color:#3d8a5d; }
|
.compose-menu-panel button.active { background:#eef8f2; color:#3d8a5d; }
|
||||||
.compose-menu-panel button:disabled { opacity:.45; cursor:not-allowed; }
|
.compose-menu-panel button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
.compose-draft-status { color:#8a848d; font-size:10px; font-weight:600; white-space:nowrap; }
|
.compose-draft-status { color:#8a848d; font-size:10px; font-weight:600; white-space:nowrap; }
|
||||||
.compose-discard { width:34px; height:34px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#8a848d; }
|
.compose-discard { width:32px; height:32px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8a848d; }
|
||||||
.compose-discard:hover { background:#fdeeed; color:#b54f3d; }
|
.compose-discard:hover { background:#fdeeed; color:#b54f3d; }
|
||||||
.compose-emoji-wrap { position:relative; }
|
.compose-emoji-wrap { position:relative; }
|
||||||
.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:6; width:188px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; width:180px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow-float); }
|
||||||
.compose-emoji-panel button { width:28px; height:28px; border:0; border-radius:7px; background:transparent; font-size:16px; }
|
.compose-emoji-panel button { width:26px; height:26px; border:0; border-radius:6px; background:transparent; font-size:15px; }
|
||||||
.compose-emoji-panel button:hover { background:#f3efec; }
|
.compose-emoji-panel button:hover { background:#f3efec; }
|
||||||
|
@media (max-width:640px) {
|
||||||
|
.compose-format-divider-advanced,.compose-format-advanced { display:none; }
|
||||||
|
.compose-format-align { display:none; }
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width:900px) {
|
@media(max-width:900px) {
|
||||||
.web-view { display:none; }.mobile-view { display:block; }
|
.web-view { display:none; }.mobile-view { display:block; }
|
||||||
|
|
@ -805,14 +839,15 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.mailbox-shell.detail-visible .mailbox-list-pane { display:flex; min-height:520px; }
|
.mailbox-shell.detail-visible .mailbox-list-pane { display:flex; min-height:520px; }
|
||||||
.mail-detail { position:static; flex:1; }
|
.mail-detail { position:static; flex:1; }
|
||||||
.mail-detail-close { display:flex!important; }
|
.mail-detail-close { display:flex!important; }
|
||||||
.compose-window { width:min(100vw - 24px,560px); max-height:calc(100vh - 24px); }
|
.compose-dock { right:12px !important; bottom:12px; }
|
||||||
.compose-window.expanded,.compose-window.detached { width:calc(100vw - 24px); max-height:calc(100vh - 24px); }
|
.compose-window { width:min(calc(100vw - 24px),580px); min-width:0; max-width:none; height:min(520px,calc(100vh - 24px)); resize:none; }
|
||||||
|
.compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
|
||||||
}
|
}
|
||||||
@media(max-width:480px) {
|
@media(max-width:480px) {
|
||||||
.account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
|
.account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
|
||||||
.compose-window { right:0 !important; bottom:0 !important; width:100vw; max-height:100vh; border-radius:14px 14px 0 0; }
|
.compose-dock { right:0 !important; bottom:0; width:100%; }
|
||||||
.compose-editor { min-height:28vh; max-height:none; }
|
.compose-dock-expanded { width:100%; }
|
||||||
.compose-action-bar { flex-wrap:wrap; }
|
.compose-window { width:100vw; height:min(88vh,100vh); border-radius:14px 14px 0 0; }
|
||||||
.compose-draft-status { display:none; }
|
.compose-draft-status { display:none; }
|
||||||
.dashboard-content-heading { align-items:center; }
|
.dashboard-content-heading { align-items:center; }
|
||||||
.dashboard-content-heading h1 { font-size:34px; }
|
.dashboard-content-heading h1 { font-size:34px; }
|
||||||
|
|
@ -1123,7 +1158,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.mail-detail.open { animation:slideInDetail var(--motion-slow) var(--ease-standard); }
|
.mail-detail.open { animation:slideInDetail var(--motion-slow) var(--ease-standard); }
|
||||||
.mail-detail-agent-bar .agent-action.subtle { height:36px; padding:0 var(--space-3); font-size:var(--text-meta); border-radius:var(--radius-sm); }
|
.mail-detail-agent-bar .agent-action.subtle { height:36px; padding:0 var(--space-3); font-size:var(--text-meta); border-radius:var(--radius-sm); }
|
||||||
.mail-detail-content h2 { margin-top:0; }
|
.mail-detail-content h2 { margin-top:0; }
|
||||||
.compose-agent-mark { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-md); background:var(--agent-soft); color:var(--agent); flex-shrink:0; }
|
.compose-agent-mark { width:30px; height:30px; display:grid; place-items:center; border-radius:9px; background:var(--agent-soft); color:var(--agent); flex-shrink:0; }
|
||||||
.mailbox-notes-pane.compact { min-height:0; border-radius:10px; border:1px solid rgba(26,24,38,0.055); background:#FFFFFF; box-shadow:0 1px 2px rgba(20,18,30,0.025),0 4px 12px rgba(20,18,30,0.03); overflow:hidden; }
|
.mailbox-notes-pane.compact { min-height:0; border-radius:10px; border:1px solid rgba(26,24,38,0.055); background:#FFFFFF; box-shadow:0 1px 2px rgba(20,18,30,0.025),0 4px 12px rgba(20,18,30,0.03); overflow:hidden; }
|
||||||
.mailbox-notes-pane.compact.empty { background:#FFFFFF; }
|
.mailbox-notes-pane.compact.empty { background:#FFFFFF; }
|
||||||
.mailbox-notes-pane.compact.empty .sticky-notes-toggle { min-height:38px; color:#77758A; font-size:11px; font-weight:600; }
|
.mailbox-notes-pane.compact.empty .sticky-notes-toggle { min-height:38px; color:#77758A; font-size:11px; font-weight:600; }
|
||||||
|
|
|
||||||
44
frontend/src/utils/composeDraft.js
Normal file
44
frontend/src/utils/composeDraft.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const DRAFT_KEY_PREFIX = "agmail_draft_";
|
||||||
|
|
||||||
|
export function draftStorageKey(windowId) {
|
||||||
|
return `${DRAFT_KEY_PREFIX}${windowId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDraft(windowId) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftStorageKey(windowId));
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDraftEmpty(windowId) {
|
||||||
|
const draft = readDraft(windowId);
|
||||||
|
if (!draft) return true;
|
||||||
|
return (
|
||||||
|
!draft.subject
|
||||||
|
&& !draft.bodyHtml
|
||||||
|
&& !draft.to?.length
|
||||||
|
&& !draft.cc?.length
|
||||||
|
&& !draft.bcc?.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftHasContent(windowId) {
|
||||||
|
return !isDraftEmpty(windowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftTitle(windowId) {
|
||||||
|
const draft = readDraft(windowId);
|
||||||
|
if (!draft) return "New message";
|
||||||
|
if (draft.subject?.trim()) return draft.subject.trim();
|
||||||
|
const first = draft.to?.[0];
|
||||||
|
if (first?.name) return first.name;
|
||||||
|
if (first?.email) return first.email.split("@")[0];
|
||||||
|
return "New message";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(windowId) {
|
||||||
|
localStorage.removeItem(draftStorageKey(windowId));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue