ultisuite-client/components/gmail/compose/use-compose-window.ts
2026-05-25 13:52:40 +02:00

561 lines
17 KiB
TypeScript

"use client"
import {
useState,
useRef,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
} from "react"
import { useEditor } 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,
} from "lucide-react"
import {
type ComposeState,
cloneComposeForPendingSend,
type Identity,
useComposeActions,
} from "@/lib/compose-context"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useComposeIdentities } from "@/components/gmail/compose-identities-sync"
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 { insertSignatureHtml, SignatureBlock, stripSignature } from "@/components/gmail/compose/compose-shared"
export function useComposeWindow(
compose: ComposeState,
threadSourceEmail: Email | null = null,
isXsSheet = false,
bindXsSheetClose?: (fn: (() => void) | null) => void
) {
const {
closeCompose,
updateCompose,
applyComposePreset,
toggleMinimize,
toggleMaximize,
restoreComposeFromSnapshot,
} = useComposeActions()
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
useScheduledMail()
const activeAccount = useActiveAccount()
const { identities: composeIdentities } = useComposeIdentities(activeAccount?.id)
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: Identity) => {
const sigSource =
identity.signatureHtml ??
(identity.defaultSignatureId ? identity.defaultSignatureId : null)
if (compose.autoInsertSignature && editor) {
const newHtml = insertSignatureHtml(editor.getHTML(), sigSource)
editor.commands.setContent(newHtml)
updateCompose(compose.id, {
from: identity,
bodyHtml: newHtml,
signatureId: identity.defaultSignatureId,
})
} 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,
identities: composeIdentities,
updateCompose,
handleIdentityChange,
clearFocusToMount,
subjectInputRef,
onRecipientsActivate: () => setRecipientsFocused(true),
}
return {
compose,
threadSourceEmail,
isXsSheet,
isInline,
isEditingScheduled,
editor,
titleText,
showFormatting,
setShowFormatting,
recipientsFocused,
setRecipientsFocused,
sendMenuOpen,
setSendMenuOpen,
isDragOver,
fieldsRef,
inlineRecipientShellRef,
subjectInputRef,
fileInputRef,
imageInputRef,
showFromField,
handleClose,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
addFiles,
removeAttachment,
handleDrop,
handleDragOver,
handleDragLeave,
recipientSummary,
showReplyAllInMenu,
ThreadKindIcon,
openInlinePreset,
openDockFromInline,
recipientFieldsProps,
toggleMinimize,
toggleMaximize,
updateCompose,
}
}