ultisuite-client/components/gmail/compose-modal.tsx
2026-05-20 16:01:08 +02:00

816 lines
25 KiB
TypeScript

"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<HTMLDivElement>(null)
const inlineRecipientShellRef = useRef<HTMLDivElement>(null)
const subjectInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(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><\/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(/<style[^>]*>[\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 = (
<div
data-compose-window
className={cn(
"relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
isInline
? "min-h-[360px] w-full rounded-xl border border-border shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]"
: isXsSheet
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
: cn(
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
compose.maximized
? readCoarsePointerMatches()
? "fixed inset-0 z-60 rounded-none"
: "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]"
)
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
<input
ref={imageInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
{/* Drop overlay */}
{isDragOver ? <ComposeDropOverlay /> : null}
{isInline ? (
<ComposeInlineRecipientHeader
compose={compose}
threadSourceEmail={threadSourceEmail}
recipientSummary={recipientSummary}
recipientsFocused={recipientsFocused}
showReplyAllInMenu={showReplyAllInMenu}
ThreadKindIcon={ThreadKindIcon}
onOpenInlinePreset={openInlinePreset}
onOpenDockFromInline={openDockFromInline}
onActivateRecipients={() => setRecipientsFocused(true)}
updateCompose={updateCompose}
recipientFieldsProps={recipientFieldsProps}
fieldsRef={fieldsRef}
inlineRecipientShellRef={inlineRecipientShellRef}
/>
) : isXsSheet ? (
<ComposeXsSheetHeader titleText={titleText} onClose={handleClose} />
) : (
<>
{/* Title bar */}
<ComposeDockTitleBar
titleText={titleText}
maximized={compose.maximized}
onMinimize={() => toggleMinimize(compose.id)}
onMaximize={() => toggleMaximize(compose.id)}
onClose={handleClose}
/>
</>
)}
{!isInline && (
<div ref={fieldsRef} className="flex shrink-0 flex-col">
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
)}
{/* Editor */}
<div className="min-h-0 flex-1 overflow-y-auto">
<EditorContent editor={editor} />
</div>
<ComposeAttachmentsList
attachments={compose.attachments}
onRemove={removeAttachment}
/>
{showFormatting ? <FormattingToolbar editor={editor} /> : null}
<ComposeBottomToolbar
compose={compose}
editor={editor}
isEditingScheduled={isEditingScheduled}
showFormatting={showFormatting}
sendMenuOpen={sendMenuOpen}
setShowFormatting={setShowFormatting}
setSendMenuOpen={setSendMenuOpen}
handleSend={handleSend}
saveScheduledEdit={saveScheduledEdit}
sendScheduledFromEditNow={sendScheduledFromEditNow}
applyScheduledPlanAt={applyScheduledPlanAt}
submitScheduledSendAt={submitScheduledSendAt}
handleClose={handleClose}
fileInputRef={fileInputRef}
imageInputRef={imageInputRef}
/>
</div>
)
if (compose.minimized && !isInline && !isXsSheet) {
return (
<div
className={cn(
MAIL_COMPOSE_TITLEBAR_CLASS,
"h-9 w-[280px] cursor-pointer shadow-lg transition-shadow hover:shadow-xl"
)}
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
if (compose.maximized && !isInline && !isXsSheet) {
return (
<>
<div
className="fixed inset-0 z-55 bg-black/50"
onClick={() => 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 (
<>
<Sheet open={xsActiveDock != null} onOpenChange={handleXsSheetOpenChange}>
<SheetContent
side="bottom"
hideClose
overlayClassName="z-[60]"
className="z-[61] h-[100dvh] max-h-[100dvh] w-full gap-0 rounded-none border-0 p-0 shadow-none duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom overflow-hidden pb-[env(safe-area-inset-bottom)]"
>
<SheetTitle className="sr-only">
{(xsActiveDock?.subject ?? "").trim() || "Nouveau message"}
</SheetTitle>
{xsActiveDock ? (
<ComposeWindow
key={xsActiveDock.id}
compose={xsActiveDock}
isXsSheet
bindXsSheetClose={bindXsSheetClose}
/>
) : null}
</SheetContent>
</Sheet>
{maximized.map((compose) => (
<div key={compose.id} className="pointer-events-auto">
<ComposeWindow compose={compose} />
</div>
))}
</>
)
}
return (
<>
{nonMaximized.map((compose) => {
const pos = positions.find((p) => p.id === compose.id)
if (!pos) return null
return (
<div
key={compose.id}
className={cn(
"pointer-events-auto fixed bottom-0 z-50 transition-all duration-300",
pos.hidden && "invisible"
)}
style={{ right: pos.right }}
>
<ComposeWindow compose={compose} />
</div>
)
})}
{maximized.map((compose) => (
<div key={compose.id} className="pointer-events-auto">
<ComposeWindow compose={compose} />
</div>
))}
</>
)
}