816 lines
25 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
)
|
|
}
|