"use client" import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, lazy, Suspense, } from "react" import { useIsXs } from "@/hooks/use-xs" import { readCoarsePointerMatches } from "@/hooks/use-touch-nav" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { useEditor, EditorContent } from "@tiptap/react" import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core" import StarterKit from "@tiptap/starter-kit" import Underline from "@tiptap/extension-underline" import Link from "@tiptap/extension-link" import TextAlign from "@tiptap/extension-text-align" import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style" import Color from "@tiptap/extension-color" import { Maximize2, Minimize2, X, ChevronDown, Paperclip, Link as LinkIcon, Smile, HardDrive, Image as ImageIcon, Lock, PenTool, MoreVertical, Trash2, Bold, Italic, Underline as UnderlineIcon, AlignLeft, AlignCenter, AlignRight, AlignJustify, List, ListOrdered, Undo, Redo, Type, Clock, Indent, Outdent, RemoveFormatting, Palette, ALargeSmall, CaseSensitive, Reply, ReplyAll, Forward, SquareArrowOutUpRight, Pencil, Send, } from "lucide-react" import { type ComposeState, type Contact, cloneComposeForPendingSend, DEFAULT_IDENTITIES, MOCK_CONTACTS, SIGNATURES, useComposeActions, useComposeWindows, } from "@/lib/compose-context" import { useScheduledMail } from "@/lib/scheduled-mail-context" import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" import type { Email } from "@/lib/email-data" import { buildThreadComposePreset, collectThreadParticipants, } from "@/lib/thread-compose-preset" import { toast } from "sonner" import { showPendingSendToast } from "@/lib/pending-send-toast" import { cn, getNextLocalWallClockDate } from "@/lib/utils" import { MAIL_COMPOSE_MENU_SELECTED_CLASS, MAIL_COMPOSE_POPOVER_CLASS, MAIL_COMPOSE_TITLEBAR_CLASS, MAIL_ICON_BTN, MAIL_MENU_SURFACE_CLASS, } from "@/lib/mail-chrome-classes" import { useTheme } from "next-themes" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import data from "@emoji-mart/data" const LazyPicker = lazy(() => import("@emoji-mart/react")) function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { const { resolvedTheme } = useTheme() return ( Chargement…}> ) } const SignatureBlock = TipTapNode.create({ name: "signatureBlock", group: "block", content: "block+", defining: true, isolating: true, parseHTML() { return [{ tag: 'div[id="ultimail-signature"]' }] }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { id: "ultimail-signature" }), 0] }, }) const SIG_REGEX = /
[\s\S]*<\/div>/ function stripSignature(html: string) { return html.replace(SIG_REGEX, "") } function insertSignatureHtml(html: string, sigId: string | null) { const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null const clean = stripSignature(html) if (!sig) return clean return clean + `

--

${sig.html}
` } /** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */ const COMPOSE_PORTAL_Z = "z-[100]" const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ function RecipientField({ label, contacts, onChange, placeholder, onActivate, autoFocus, onAutoFocusDone, }: { label: string contacts: Contact[] onChange: (contacts: Contact[]) => void placeholder?: string onActivate?: () => void autoFocus?: boolean onAutoFocusDone?: () => void }) { const [inputValue, setInputValue] = useState("") const [showSuggestions, setShowSuggestions] = useState(false) const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(0) const inputRef = useRef(null) const containerRef = useRef(null) const suggestions = useMemo(() => { if (!inputValue.trim()) return [] const q = inputValue.toLowerCase() return MOCK_CONTACTS.filter( (c) => !contacts.some((existing) => existing.email === c.email) && (c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)) ).slice(0, 6) }, [inputValue, contacts]) useEffect(() => { setSelectedSuggestionIdx(0) }, [suggestions.length]) useEffect(() => { if (!autoFocus) return const id = window.requestAnimationFrame(() => { inputRef.current?.focus() onAutoFocusDone?.() }) return () => window.cancelAnimationFrame(id) }, [autoFocus, onAutoFocusDone]) const addContact = useCallback( (contact: Contact) => { if (!contacts.some((c) => c.email === contact.email)) { onChange([...contacts, contact]) } setInputValue("") setShowSuggestions(false) }, [contacts, onChange] ) const tryAddRawEmail = useCallback( (raw: string) => { const trimmed = raw.trim().replace(/,$/, "") if (!trimmed) return const matchedContact = MOCK_CONTACTS.find( (c) => c.email.toLowerCase() === trimmed.toLowerCase() ) if (matchedContact) { addContact(matchedContact) } else if (EMAIL_REGEX.test(trimmed)) { addContact({ name: trimmed, email: trimmed }) } }, [addContact] ) const removeContact = useCallback( (email: string) => { onChange(contacts.filter((c) => c.email !== email)) }, [contacts, onChange] ) const handleKeyDown = (e: React.KeyboardEvent) => { if ( (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === " ") && inputValue.trim() ) { e.preventDefault() if (showSuggestions && suggestions.length > 0) { addContact(suggestions[selectedSuggestionIdx]) } else { tryAddRawEmail(inputValue) } return } if (e.key === "Backspace" && !inputValue && contacts.length > 0) { onChange(contacts.slice(0, -1)) return } if (showSuggestions && suggestions.length > 0) { if (e.key === "ArrowDown") { e.preventDefault() setSelectedSuggestionIdx((i) => i < suggestions.length - 1 ? i + 1 : 0 ) } else if (e.key === "ArrowUp") { e.preventDefault() setSelectedSuggestionIdx((i) => i > 0 ? i - 1 : suggestions.length - 1 ) } } if (e.key === "Escape") { setShowSuggestions(false) } } const getInitials = (name: string) => { const parts = name.split(" ").filter(Boolean) return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : (parts[0]?.[0] ?? "").toUpperCase() } const pillColors = [ "bg-blue-600", "bg-purple-600", "bg-emerald-600", "bg-amber-600", "bg-rose-600", "bg-teal-600", "bg-indigo-600", ] const getColor = (email: string) => { let hash = 0 for (let i = 0; i < email.length; i++) { hash = email.charCodeAt(i) + ((hash << 5) - hash) } return pillColors[Math.abs(hash) % pillColors.length] } return (
{ inputRef.current?.focus() onActivate?.() }} > {label} {contacts.map((c) => ( {getInitials(c.name)} {c.name === c.email ? c.email : c.name} ))} { setInputValue(e.target.value) setShowSuggestions(true) }} onKeyDown={handleKeyDown} onFocus={() => { setShowSuggestions(true) onActivate?.() }} onBlur={() => { setTimeout(() => { setShowSuggestions(false) if (inputValue.trim()) tryAddRawEmail(inputValue) }, 200) }} placeholder={contacts.length === 0 ? placeholder : undefined} className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-[#202124] outline-none placeholder:text-[#80868b]" />
{showSuggestions && suggestions.length > 0 && (
{suggestions.map((s, idx) => ( ))}
)}
) } function AlignmentDropdown({ editor, btnClass, activeClass, }: { editor: NonNullable> btnClass: string activeClass: string }) { const currentIcon = editor.isActive({ textAlign: "center" }) ? AlignCenter : editor.isActive({ textAlign: "right" }) ? AlignRight : editor.isActive({ textAlign: "justify" }) ? AlignJustify : AlignLeft const CurrentIcon = currentIcon return ( editor.chain().focus().setTextAlign("left").run()} className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} > Aligner à gauche editor.chain().focus().setTextAlign("center").run()} className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} > Centrer editor.chain().focus().setTextAlign("right").run()} className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} > Aligner à droite editor.chain().focus().setTextAlign("justify").run()} className={cn(editor.isActive({ textAlign: "justify" }) && "bg-[#e8eaed]")} > Justifier ) } const FONT_FAMILIES = [ { label: "Sans Serif", value: "sans-serif" }, { label: "Serif", value: "serif" }, { label: "Monospace", value: "monospace" }, { label: "Cursive", value: "cursive" }, { label: "Comic Sans MS", value: "Comic Sans MS, cursive" }, { label: "Garamond", value: "Garamond, serif" }, { label: "Georgia", value: "Georgia, serif" }, { label: "Impact", value: "Impact, sans-serif" }, { label: "Tahoma", value: "Tahoma, sans-serif" }, { label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" }, { label: "Verdana", value: "Verdana, sans-serif" }, ] const FONT_SIZES = [ { label: "Très petit", value: "10px" }, { label: "Petit", value: "13px" }, { label: "Normal", value: "" }, { label: "Grand", value: "18px" }, { label: "Très grand", value: "24px" }, { label: "Énorme", value: "32px" }, ] const TEXT_COLORS = [ "#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff", "#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2", "#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984", "#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64", ] function FontDropdown({ editor, btnClass, }: { editor: NonNullable> btnClass: string }) { return ( {FONT_FAMILIES.map((f) => ( editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()} style={{ fontFamily: f.value }} className={cn( editor.isActive("textStyle", { fontFamily: f.value }) && "bg-[#e8eaed]" )} > {f.label} ))} ) } function FontSizeDropdown({ editor, btnClass, }: { editor: NonNullable> btnClass: string }) { return ( {FONT_SIZES.map((s) => ( { if (s.value) { editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run() } else { editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run() } }} style={s.value ? { fontSize: s.value } : undefined} className={cn( s.value && editor.isActive("textStyle", { fontSize: s.value }) && "bg-[#e8eaed]" )} > {s.label} ))} ) } function ColorDropdown({ editor, btnClass, }: { editor: NonNullable> btnClass: string }) { const [tab, setTab] = useState<"text" | "bg">("text") return ( e.preventDefault()} >
{TEXT_COLORS.map((color) => (
) } function FormattingToolbar({ editor, }: { editor: Editor | null }) { if (!editor) return null const btnClass = "flex h-7 w-7 items-center justify-center rounded hover:bg-[#f1f3f4] text-[#5f6368] transition-colors disabled:opacity-40" const activeClass = "bg-[#e8eaed] text-[#202124]" const sep = return (
{/* Undo / Redo */} {sep} {/* Font */} {sep} {/* Font size */} {sep} {/* Bold, Italic, Underline, Colors */} {sep} {/* Alignment dropdown, lists, indent/outdent, remove formatting */}
) } function EmojiButton({ editor, }: { editor: Editor | null }) { const [open, setOpen] = useState(false) const handleSelect = useCallback( (emoji: { native: string }) => { editor?.chain().focus().insertContent(emoji.native).run() setOpen(false) }, [editor] ) if (!editor) return null return ( e.preventDefault()} > ) } function LinkButton({ editor, }: { editor: Editor | null }) { const [open, setOpen] = useState(false) const [url, setUrl] = useState("") const [text, setText] = useState("") if (!editor) return null const isLinkActive = editor.isActive("link") const handleToggle = () => { if (isLinkActive) { editor.chain().focus().extendMarkRange("link").unsetLink().run() return } setOpen(true) } const handleOpen = (isOpen: boolean) => { if (isOpen) { const { from, to, empty } = editor.state.selection if (isLinkActive) { const attrs = editor.getAttributes("link") setUrl(attrs.href || "") const selectedText = editor.state.doc.textBetween(from, to, " ") setText(selectedText) } else if (!empty) { const selectedText = editor.state.doc.textBetween(from, to, " ") setText(selectedText) setUrl("") } else { setText("") setUrl("") } } setOpen(isOpen) } const handleInsert = () => { if (!url.trim()) return const href = url.match(/^https?:\/\//) ? url : `https://${url}` const { empty } = editor.state.selection if (empty && !isLinkActive) { const displayText = text.trim() || href editor .chain() .focus() .insertContent(`${displayText}`) .run() } else { if (text.trim() && text.trim() !== editor.state.doc.textBetween( editor.state.selection.from, editor.state.selection.to, " " )) { editor .chain() .focus() .deleteSelection() .insertContent(`${text.trim()}`) .run() } else { editor .chain() .focus() .extendMarkRange("link") .setLink({ href }) .run() } } setOpen(false) setUrl("") setText("") } const handleRemoveLink = () => { editor.chain().focus().extendMarkRange("link").unsetLink().run() setOpen(false) setUrl("") setText("") } return ( e.preventDefault()} >
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
setText(e.target.value)} placeholder="Texte du lien" className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" />
setUrl(e.target.value)} placeholder="https://example.com" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() handleInsert() } }} className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" autoFocus />
{isLinkActive ? ( ) : ( )}
) } function SignatureButton({ editor, compose, }: { editor: Editor | null compose: ComposeState }) { const { updateCompose } = useComposeActions() const replaceSignature = useCallback( (sigId: string | null) => { if (!editor) return const newHtml = insertSignatureHtml(editor.getHTML(), sigId) editor.commands.setContent(newHtml) updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId }) }, [editor, compose.id, updateCompose] ) const toggleAutoInsert = useCallback(() => { const newVal = !compose.autoInsertSignature updateCompose(compose.id, { autoInsertSignature: newVal }) if (!newVal) { replaceSignature(null) } else { const sigId = compose.from.defaultSignatureId if (sigId) replaceSignature(sigId) } }, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature]) if (!editor) return null return ( { e.preventDefault() toggleAutoInsert() }} className="gap-2" > {compose.autoInsertSignature && } Insérer automatiquement replaceSignature(null)} className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)} > {!compose.signatureId && } Aucune signature {SIGNATURES.map((sig) => ( replaceSignature(sig.id)} className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)} > {compose.signatureId === sig.id && } {sig.name} ))} ) } interface ComposeRecipientFieldsProps { compose: ComposeState isInline: boolean showFromField: boolean updateCompose: (id: string, patch: Partial) => void handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void clearFocusToMount: () => void subjectInputRef: React.RefObject onRecipientsActivate: () => void } function ComposeRecipientFields({ compose, isInline, showFromField, updateCompose, handleIdentityChange, clearFocusToMount, subjectInputRef, onRecipientsActivate, }: ComposeRecipientFieldsProps) { const dockNewMessageTabOrder = !isInline && !compose.threadEmailId && !compose.threadKind const forwardDockSkipSubjectTab = !isInline && compose.threadKind === "forward" return ( <> {showFromField && (
De {DEFAULT_IDENTITIES.map((id) => ( handleIdentityChange(id)} >
{id.name} {id.email}
))}
)} {showFromField && !isInline && (
)}
0 ? "À" : "Destinataires"} contacts={compose.to} onChange={(to) => updateCompose(compose.id, { to })} onActivate={onRecipientsActivate} autoFocus={Boolean(compose.focusToOnMount)} onAutoFocusDone={clearFocusToMount} />
{showFromField && (!compose.showCc || !compose.showBcc) && (
{!compose.showCc && ( )} {!compose.showBcc && ( )}
)}
{!isInline &&
} {compose.showCc && ( <> updateCompose(compose.id, { cc })} /> {!isInline &&
} )} {compose.showBcc && ( <> updateCompose(compose.id, { bcc })} /> {!isInline &&
} )} {!isInline && ( <> updateCompose(compose.id, { subject: e.target.value }) } placeholder="Objet" tabIndex={forwardDockSkipSubjectTab ? -1 : undefined} className="h-8 w-full border-none bg-transparent px-3 text-sm text-[#202124] outline-none placeholder:text-[#80868b]" />
)} ) } export function ComposeWindow({ compose, threadSourceEmail = null, isXsSheet = false, bindXsSheetClose, }: { compose: ComposeState /** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */ threadSourceEmail?: Email | null /** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */ isXsSheet?: boolean bindXsSheetClose?: (fn: (() => void) | null) => void }) { const { closeCompose, updateCompose, applyComposePreset, toggleMinimize, toggleMaximize, restoreComposeFromSnapshot, } = useComposeActions() const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = useScheduledMail() const isInline = compose.placement === "inline" const isEditingScheduled = compose.editingScheduledId != null const [showFormatting, setShowFormatting] = useState(false) const [recipientsFocused, setRecipientsFocused] = useState(false) const [sendMenuOpen, setSendMenuOpen] = useState(false) const [isDragOver, setIsDragOver] = useState(false) const fieldsRef = useRef(null) const inlineRecipientShellRef = useRef(null) const subjectInputRef = useRef(null) const fileInputRef = useRef(null) const imageInputRef = useRef(null) const snapBodyCaretToStartOnHeaderTab = !isInline && !compose.threadEmailId && !compose.threadKind && !compose.editingScheduledId const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit, Underline, Link.configure({ openOnClick: false }), TextStyle, Color, BackgroundColor, FontFamily, FontSize, TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }), SignatureBlock, ] as Extensions, content: compose.bodyHtml, onUpdate: ({ editor: ed }) => { updateCompose(compose.id, { bodyHtml: ed.getHTML() }) }, onFocus: ({ editor: ed, event }) => { if (!snapBodyCaretToStartOnHeaderTab) return const rt = event.relatedTarget as Node | null if (!rt || !fieldsRef.current?.contains(rt)) return window.requestAnimationFrame(() => { if (!ed.view.hasFocus()) return try { ed.chain().setTextSelection(1).run() } catch { /* empty doc edge */ } }) }, editorProps: { attributes: { class: cn( "prose prose-sm max-w-none px-3 py-2 text-sm text-[#202124] outline-none focus:outline-none", isInline ? "min-h-[200px]" : isXsSheet ? "min-h-[min(36vh,280px)]" : "min-h-[150px]" ), }, }, }) const titleText = compose.subject || "Nouveau message" const bodyWithoutSig = stripSignature(compose.bodyHtml) .replace(/

<\/p>/g, "") .trim() const hasContent = compose.subject.trim() !== "" || compose.to.length > 0 || compose.cc.length > 0 || compose.bcc.length > 0 || compose.attachments.length > 0 || bodyWithoutSig !== "" const handleIdentityChange = useCallback( (identity: (typeof DEFAULT_IDENTITIES)[number]) => { if (compose.autoInsertSignature && editor) { const sigId = identity.defaultSignatureId const newHtml = insertSignatureHtml(editor.getHTML(), sigId) editor.commands.setContent(newHtml) updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId }) } else { updateCompose(compose.id, { from: identity }) } }, [compose.id, compose.autoInsertSignature, editor, updateCompose] ) const handleClose = () => { const threadInlineDiscard = isInline && compose.threadEmailId ? ({ discardThreadReplyDraft: true } as const) : undefined if (!hasContent) { closeCompose(compose.id, threadInlineDiscard) } else { updateCompose(compose.id, { savedAt: Date.now() }) closeCompose(compose.id, threadInlineDiscard) } } const handleCloseRef = useRef(handleClose) handleCloseRef.current = handleClose useLayoutEffect(() => { if (!isXsSheet || !bindXsSheetClose) return bindXsSheetClose(() => { handleCloseRef.current() }) return () => bindXsSheetClose(null) }, [isXsSheet, bindXsSheetClose, compose.id]) const htmlToPreviewText = useCallback((html: string) => { return html .replace(/]*>[\s\S]*?<\/style>/gi, " ") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim() }, []) const handleSend = useCallback(() => { if (compose.to.length === 0) return const bodyHtml = editor?.getHTML() ?? compose.bodyHtml const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) closeCompose(compose.id, { sent: true }) showPendingSendToast({ onCommit: async () => {}, onCancel: () => restoreComposeFromSnapshot(snapshot), }) }, [ closeCompose, compose, editor, restoreComposeFromSnapshot, ]) const submitScheduledSendAt = useCallback( async (sendAt: Date) => { if (isEditingScheduled) return if (compose.to.length === 0) return setSendMenuOpen(false) const bodyHtml = editor?.getHTML() ?? compose.bodyHtml await scheduleSend({ sendAtIso: sendAt.toISOString(), to: compose.to.map((c) => ({ name: c.name, email: c.email })), subject: compose.subject, previewText: htmlToPreviewText(bodyHtml).slice(0, 500), bodyHtml, }) const whenLabel = sendAt.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short", }) toast.message(`Ce mail sera envoyé le ${whenLabel}`) closeCompose(compose.id, { sent: true }) }, [ isEditingScheduled, compose.bodyHtml, compose.id, compose.subject, compose.to, closeCompose, editor, htmlToPreviewText, scheduleSend, ] ) const buildSchedulePayload = useCallback( (sendAtIso: string): ScheduleSendPayload | null => { if (compose.to.length === 0) return null const bodyHtml = editor?.getHTML() ?? compose.bodyHtml return { sendAtIso, to: compose.to.map((c) => ({ name: c.name, email: c.email })), subject: compose.subject, previewText: htmlToPreviewText(bodyHtml).slice(0, 500), bodyHtml, } }, [compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText] ) const saveScheduledEdit = useCallback(async () => { const id = compose.editingScheduledId if (!id) return const iso = compose.scheduledSendAtIso ?? new Date().toISOString() const payload = buildSchedulePayload(iso) if (!payload) return await requestUpdateScheduledSend(id, payload) toast.message("Modifications enregistrées") closeCompose(compose.id) }, [ buildSchedulePayload, closeCompose, compose.editingScheduledId, compose.id, compose.scheduledSendAtIso, requestUpdateScheduledSend, ]) const sendScheduledFromEditNow = useCallback(async () => { const id = compose.editingScheduledId if (!id) return setSendMenuOpen(false) const bodyHtml = editor?.getHTML() ?? compose.bodyHtml const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) closeCompose(compose.id, { sent: true }) showPendingSendToast({ onCommit: async () => { const schedId = snapshot.editingScheduledId if (!schedId || snapshot.to.length === 0) return const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString() const body = snapshot.bodyHtml const payload = { sendAtIso: iso, to: snapshot.to.map((c) => ({ name: c.name, email: c.email })), subject: snapshot.subject, previewText: htmlToPreviewText(body).slice(0, 500), bodyHtml: body, } await requestUpdateScheduledSend(schedId, payload) await requestSendScheduledNow(schedId) }, onCancel: () => restoreComposeFromSnapshot(snapshot), }) }, [ closeCompose, compose, editor, htmlToPreviewText, requestSendScheduledNow, requestUpdateScheduledSend, restoreComposeFromSnapshot, ]) const applyScheduledPlanAt = useCallback( async (sendAt: Date) => { const id = compose.editingScheduledId if (!id) return setSendMenuOpen(false) const iso = sendAt.toISOString() const payload = buildSchedulePayload(iso) if (!payload) return await requestUpdateScheduledSend(id, payload) updateCompose(compose.id, { scheduledSendAtIso: iso }) const whenLabel = sendAt.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short", }) toast.message(`Envoi planifié le ${whenLabel}`) }, [ buildSchedulePayload, compose.editingScheduledId, compose.id, requestUpdateScheduledSend, updateCompose, ] ) const addFiles = useCallback((files: FileList | File[]) => { const newAttachments = Array.from(files).map((file) => ({ id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file, name: file.name, size: file.size, type: file.type, })) updateCompose(compose.id, { attachments: [...compose.attachments, ...newAttachments], }) }, [compose.id, compose.attachments, updateCompose]) const removeAttachment = useCallback((attId: string) => { updateCompose(compose.id, { attachments: compose.attachments.filter((a) => a.id !== attId), }) }, [compose.id, compose.attachments, updateCompose]) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(false) if (e.dataTransfer.files.length > 0) { addFiles(e.dataTransfer.files) } }, [addFiles]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false) } }, []) const showFromField = recipientsFocused || isXsSheet useLayoutEffect(() => { if (!isInline || !compose.focusToOnMount) return setRecipientsFocused(true) }, [isInline, compose.focusToOnMount]) useEffect(() => { if (!recipientsFocused) return const handleClickOutside = (e: Event) => { const target = e.target as Node const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current if (root && !root.contains(target)) { const el = e.target as HTMLElement | null const portal = el?.closest?.( "[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']" ) if (portal) return setRecipientsFocused(false) if (compose.showCc && compose.cc.length === 0) { updateCompose(compose.id, { showCc: false }) } if (compose.showBcc && compose.bcc.length === 0) { updateCompose(compose.id, { showBcc: false }) } } } document.addEventListener("pointerdown", handleClickOutside) return () => document.removeEventListener("pointerdown", handleClickOutside) }, [ recipientsFocused, isInline, compose.showCc, compose.showBcc, compose.cc.length, compose.bcc.length, compose.id, updateCompose, ]) useEffect(() => { if (!editor || editor.isDestroyed) return const next = compose.bodyHtml if (editor.getHTML() === next) return editor.commands.setContent(next, { emitUpdate: false }) }, [compose.bodyHtml, compose.threadKind, editor]) useEffect(() => { if (!compose.focusSubjectOnMount || isInline) return const id = window.requestAnimationFrame(() => { subjectInputRef.current?.focus() updateCompose(compose.id, { focusSubjectOnMount: false }) }) return () => window.cancelAnimationFrame(id) }, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose]) useEffect(() => { if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return let cancelled = false const outer = window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { if (cancelled || !editor || editor.isDestroyed) return try { editor.chain().focus().setTextSelection(1).run() } catch { editor.chain().focus().run() } updateCompose(compose.id, { focusBodyOnMount: false }) }) }) return () => { cancelled = true window.cancelAnimationFrame(outer) } }, [compose.focusBodyOnMount, compose.id, editor, updateCompose]) const clearFocusToMount = useCallback(() => { updateCompose(compose.id, { focusToOnMount: false }) }, [compose.id, updateCompose]) const ThreadKindIcon = compose.threadKind === "forward" ? Forward : compose.threadKind === "replyAll" ? ReplyAll : Reply const recipientSummary = compose.to.length === 0 ? "Destinataires" : compose.to.length === 1 && compose.to[0] ? compose.to[0].name === compose.to[0].email ? compose.to[0].email : `${compose.to[0].name} <${compose.to[0].email}>` : `${compose.to.length} destinataires` const showReplyAllInMenu = useMemo( () => Boolean( threadSourceEmail && collectThreadParticipants(threadSourceEmail).length > 1 ), [threadSourceEmail] ) const openInlinePreset = useCallback( (kind: "reply" | "replyAll" | "forward") => { if (!threadSourceEmail) return applyComposePreset( compose.id, buildThreadComposePreset(threadSourceEmail, kind) ) }, [threadSourceEmail, applyComposePreset, compose.id] ) const openDockFromInline = useCallback( (opts?: { focusSubject?: boolean }) => { setRecipientsFocused(false) updateCompose(compose.id, { placement: "dock", threadEmailId: null, focusToOnMount: false, focusBodyOnMount: false, minimized: false, maximized: false, focusSubjectOnMount: Boolean(opts?.focusSubject), }) }, [compose.id, updateCompose] ) const recipientFieldsProps = { compose, isInline, showFromField, updateCompose, handleIdentityChange, clearFocusToMount, subjectInputRef, onRecipientsActivate: () => setRecipientsFocused(true), } const modalContent = (

{/* Hidden file inputs */} { if (e.target.files && e.target.files.length > 0) { addFiles(e.target.files) e.target.value = "" } }} /> { if (e.target.files && e.target.files.length > 0) { addFiles(e.target.files) e.target.value = "" } }} /> {/* Drop overlay */} {isDragOver && (

Déposer les fichiers ici

)} {isInline ? (
e.preventDefault()} > openInlinePreset("reply")} > Répondre {showReplyAllInMenu ? ( openInlinePreset("replyAll")} > Répondre à tous ) : null} openInlinePreset("forward")} > Transférer openDockFromInline({ focusSubject: true })}> Modifier l'objet openDockFromInline()}> Ouvrir une fenêtre de réponse {!recipientsFocused && (!compose.showCc || !compose.showBcc) ? (
{!compose.showCc ? ( ) : null} {!compose.showBcc ? ( ) : null}
) : null}
) : isXsSheet ? (
{titleText}
) : ( <> {/* Title bar */}
toggleMinimize(compose.id)} > {titleText}
)} {!isInline && (
)} {/* Editor */}
{/* Attachments */} {compose.attachments.length > 0 && (
{compose.attachments.map((att) => (
{att.type.startsWith("image/") ? ( ) : ( )} {att.name} {att.size < 1024 ? `${att.size} o` : att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} Ko` : `${(att.size / 1048576).toFixed(1)} Mo`}
))}
)} {/* Formatting toolbar (toggle) */} {showFormatting && } {/* Bottom toolbar */}
{/* Send / save + dropdown */}
{isEditingScheduled ? ( <> { void sendScheduledFromEditNow() }} > Envoyer maintenant Planifier { void applyScheduledPlanAt( new Date(Date.now() + 60 * 60 * 1000) ) }} > Envoyer dans une heure { void applyScheduledPlanAt( getNextLocalWallClockDate(9, 0) ) }} > Envoyer à 9h ) : ( <> { void submitScheduledSendAt( new Date(Date.now() + 60 * 60 * 1000) ) }} > Envoyer dans une heure { void submitScheduledSendAt( getNextLocalWallClockDate(9, 0) ) }} > Envoyer à 9h setSendMenuOpen(false)}> Programmer l'envoi )}
{/* Toolbar icons */}
) if (compose.minimized && !isInline && !isXsSheet) { return (
toggleMinimize(compose.id)} > {titleText}
) } if (compose.maximized && !isInline && !isXsSheet) { return ( <>
toggleMaximize(compose.id)} /> {modalContent} ) } return modalContent } export function ComposeModalManager() { const { composeWindows } = useComposeWindows() const isXs = useIsXs() const nonMaximized = composeWindows.filter( (w) => !w.maximized && w.placement !== "inline" ) const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline") const xsSheetCloseRef = useRef<(() => void) | null>(null) const bindXsSheetClose = useCallback((fn: (() => void) | null) => { xsSheetCloseRef.current = fn }, []) /** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */ const xsActiveDock = isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null const handleXsSheetOpenChange = useCallback((open: boolean) => { if (!open) { xsSheetCloseRef.current?.() } }, []) const MODAL_WIDTH = 500 const MINIMIZED_WIDTH = 280 const GAP = 12 const RIGHT_OFFSET = 80 const positions = useMemo(() => { const reversed = [...nonMaximized].reverse() const result: { id: string; right: number; hidden: boolean }[] = [] let cursor = RIGHT_OFFSET for (let i = 0; i < reversed.length; i++) { const w = reversed[i] const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH result.push({ id: w.id, right: cursor, hidden: i >= 2 && !w.minimized, }) cursor += width + GAP } return result }, [nonMaximized]) if (isXs) { return ( <> {(xsActiveDock?.subject ?? "").trim() || "Nouveau message"} {xsActiveDock ? ( ) : null} {maximized.map((compose) => (
))} ) } return ( <> {nonMaximized.map((compose) => { const pos = positions.find((p) => p.id === compose.id) if (!pos) return null return (
) })} {maximized.map((compose) => (
))} ) }