"use client" import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, } 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 { 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 { Reply, ReplyAll, Forward, Maximize2, Minimize2, X, } from "lucide-react" import { type ComposeState, cloneComposeForPendingSend, DEFAULT_IDENTITIES, 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 } from "@/lib/utils" import { MAIL_COMPOSE_TITLEBAR_CLASS, MAIL_ICON_BTN, } from "@/lib/mail-chrome-classes" import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients" import { ComposeBottomToolbar, FormattingToolbar, } from "@/components/gmail/compose/compose-toolbar" import { ComposeAttachmentsList, ComposeDockTitleBar, ComposeDropOverlay, ComposeInlineRecipientHeader, ComposeXsSheetHeader, } from "@/components/gmail/compose/compose-editor-chrome" import { SignatureBlock, stripSignature, insertSignatureHtml } from "@/components/gmail/compose/compose-shared" 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-foreground 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 ? : null} {isInline ? ( setRecipientsFocused(true)} updateCompose={updateCompose} recipientFieldsProps={recipientFieldsProps} fieldsRef={fieldsRef} inlineRecipientShellRef={inlineRecipientShellRef} /> ) : isXsSheet ? ( ) : ( <> {/* Title bar */} toggleMinimize(compose.id)} onMaximize={() => toggleMaximize(compose.id)} onClose={handleClose} /> )} {!isInline && (
)} {/* Editor */}
{showFormatting ? : null}
) 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) => (
))} ) }