diff --git a/frontend/src/components/ComposeDock.jsx b/frontend/src/components/ComposeDock.jsx new file mode 100644 index 0000000..2fe2292 --- /dev/null +++ b/frontend/src/components/ComposeDock.jsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ComposeModal from "./ComposeModal"; +import { clearDraft, draftHasContent, draftTitle, isDraftEmpty } from "../utils/composeDraft"; + +const AGENT_PANEL_WIDTH = 320; +const MAIL_GAP = 16; +const MAX_EXPANDED = 2; +const ICON_STROKE = 1.7; + +function useViewport() { + const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); + useEffect(() => { + function onResize() { + setSize({ width: window.innerWidth, height: window.innerHeight }); + } + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + return size; +} + +export default function ComposeDock({ account, onSent, agentOpen = true }) { + const [windows, setWindows] = useState([]); + const [overflowOpen, setOverflowOpen] = useState(false); + const focusRef = useRef(null); + const viewport = useViewport(); + + const agentReserve = agentOpen && viewport.width >= 960 ? AGENT_PANEL_WIDTH + MAIL_GAP : 0; + const dockRight = agentReserve + 16; + + const expandedWindows = useMemo( + () => windows.filter((window) => !window.minimized && !window.expanded), + [windows], + ); + const expandedLarge = useMemo( + () => windows.find((window) => window.expanded && !window.minimized), + [windows], + ); + const minimizedWindows = useMemo( + () => windows.filter((window) => window.minimized), + [windows], + ); + + const focusWindow = useCallback((windowId) => { + setWindows((current) => { + const target = current.find((window) => window.id === windowId); + if (!target) return current; + const others = current.filter((window) => window.id !== windowId); + return [{ ...target, focused: true, lastActive: Date.now() }, ...others]; + }); + focusRef.current = windowId; + }, []); + + const openCompose = useCallback(() => { + setWindows((current) => { + const blank = current.find((window) => !window.minimized && isDraftEmpty(window.id)); + if (blank) { + focusRef.current = blank.id; + return [ + { ...blank, focused: true, lastActive: Date.now() }, + ...current.filter((window) => window.id !== blank.id), + ]; + } + + const expanded = current.filter((window) => !window.minimized && !window.expanded); + let next = [...current]; + if (expanded.length >= MAX_EXPANDED) { + const candidate = [...expanded] + .sort((a, b) => (a.lastActive || 0) - (b.lastActive || 0)) + .find((window) => window.id !== focusRef.current); + if (candidate) { + next = next.map((window) => ( + window.id === candidate.id ? { ...window, minimized: true } : window + )); + } + } + + const id = `compose-${Date.now()}`; + focusRef.current = id; + return [{ id, minimized: false, expanded: false, focused: true, lastActive: Date.now() }, ...next]; + }); + }, []); + + useEffect(() => { + window.agmailOpenCompose = openCompose; + return () => { + delete window.agmailOpenCompose; + }; + }, [openCompose]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const popupId = params.get("compose"); + if (params.get("popup") === "compose" && popupId) { + setWindows([{ id: popupId, minimized: false, expanded: true, focused: true, lastActive: Date.now() }]); + } + }, []); + + function updateWindow(windowId, patch) { + setWindows((current) => current.map((window) => ( + window.id === windowId ? { ...window, ...patch, lastActive: Date.now() } : window + ))); + } + + function removeWindow(windowId) { + setWindows((current) => current.filter((window) => window.id !== windowId)); + } + + function handleClose(windowId) { + if (draftHasContent(windowId)) { + updateWindow(windowId, { minimized: true, expanded: false }); + return; + } + removeWindow(windowId); + } + + function handleDiscard(windowId) { + clearDraft(windowId); + removeWindow(windowId); + } + + function handleChipClose(windowId) { + if (draftHasContent(windowId) && !window.confirm("Discard this draft?")) return; + clearDraft(windowId); + removeWindow(windowId); + } + + function handleSent(windowId) { + removeWindow(windowId); + onSent?.(windowId); + } + + const visibleExpanded = expandedWindows.slice(0, MAX_EXPANDED); + const hiddenMinimized = minimizedWindows.slice(3); + const visibleChips = minimizedWindows.slice(0, 3); + + return ( + <> +