550 lines
16 KiB
TypeScript
550 lines
16 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,
|
|
DEFAULT_IDENTITIES,
|
|
useComposeActions,
|
|
} 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 { 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 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),
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|