ultisuite-client/components/gmail/compose-modal.tsx

2338 lines
75 KiB
TypeScript

"use client"
import {
useState,
useRef,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
lazy,
Suspense,
} 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 { Editor, Node as TipTapNode, mergeAttributes, 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 {
Maximize2,
Minimize2,
X,
ChevronDown,
Paperclip,
Link as LinkIcon,
Smile,
HardDrive,
Image as ImageIcon,
Lock,
PenTool,
MoreVertical,
Trash2,
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Undo,
Redo,
Type,
Clock,
Indent,
Outdent,
RemoveFormatting,
Palette,
ALargeSmall,
CaseSensitive,
Reply,
ReplyAll,
Forward,
SquareArrowOutUpRight,
Pencil,
Send,
} from "lucide-react"
import {
type ComposeState,
type Contact,
cloneComposeForPendingSend,
DEFAULT_IDENTITIES,
MOCK_CONTACTS,
SIGNATURES,
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, getNextLocalWallClockDate } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import data from "@emoji-mart/data"
const LazyPicker = lazy(() => import("@emoji-mart/react"))
function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-[#5f6368]">Chargement</div>}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}
locale="fr"
theme="light"
previewPosition="none"
skinTonePosition="search"
set="native"
/>
</Suspense>
)
}
const SignatureBlock = TipTapNode.create({
name: "signatureBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'div[id="ultimail-signature"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { id: "ultimail-signature" }), 0]
},
})
const SIG_REGEX = /<div id="ultimail-signature">[\s\S]*<\/div>/
function stripSignature(html: string) {
return html.replace(SIG_REGEX, "")
}
function insertSignatureHtml(html: string, sigId: string | null) {
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
const clean = stripSignature(html)
if (!sig) return clean
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
}
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
const COMPOSE_PORTAL_Z = "z-[100]"
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function RecipientField({
label,
contacts,
onChange,
placeholder,
onActivate,
autoFocus,
onAutoFocusDone,
}: {
label: string
contacts: Contact[]
onChange: (contacts: Contact[]) => void
placeholder?: string
onActivate?: () => void
autoFocus?: boolean
onAutoFocusDone?: () => void
}) {
const [inputValue, setInputValue] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const suggestions = useMemo(() => {
if (!inputValue.trim()) return []
const q = inputValue.toLowerCase()
return MOCK_CONTACTS.filter(
(c) =>
!contacts.some((existing) => existing.email === c.email) &&
(c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q))
).slice(0, 6)
}, [inputValue, contacts])
useEffect(() => {
setSelectedSuggestionIdx(0)
}, [suggestions.length])
useEffect(() => {
if (!autoFocus) return
const id = window.requestAnimationFrame(() => {
inputRef.current?.focus()
onAutoFocusDone?.()
})
return () => window.cancelAnimationFrame(id)
}, [autoFocus, onAutoFocusDone])
const addContact = useCallback(
(contact: Contact) => {
if (!contacts.some((c) => c.email === contact.email)) {
onChange([...contacts, contact])
}
setInputValue("")
setShowSuggestions(false)
},
[contacts, onChange]
)
const tryAddRawEmail = useCallback(
(raw: string) => {
const trimmed = raw.trim().replace(/,$/, "")
if (!trimmed) return
const matchedContact = MOCK_CONTACTS.find(
(c) => c.email.toLowerCase() === trimmed.toLowerCase()
)
if (matchedContact) {
addContact(matchedContact)
} else if (EMAIL_REGEX.test(trimmed)) {
addContact({ name: trimmed, email: trimmed })
}
},
[addContact]
)
const removeContact = useCallback(
(email: string) => {
onChange(contacts.filter((c) => c.email !== email))
},
[contacts, onChange]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
(e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === " ") &&
inputValue.trim()
) {
e.preventDefault()
if (showSuggestions && suggestions.length > 0) {
addContact(suggestions[selectedSuggestionIdx])
} else {
tryAddRawEmail(inputValue)
}
return
}
if (e.key === "Backspace" && !inputValue && contacts.length > 0) {
onChange(contacts.slice(0, -1))
return
}
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedSuggestionIdx((i) =>
i < suggestions.length - 1 ? i + 1 : 0
)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedSuggestionIdx((i) =>
i > 0 ? i - 1 : suggestions.length - 1
)
}
}
if (e.key === "Escape") {
setShowSuggestions(false)
}
}
const getInitials = (name: string) => {
const parts = name.split(" ").filter(Boolean)
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: (parts[0]?.[0] ?? "").toUpperCase()
}
const pillColors = [
"bg-blue-600",
"bg-purple-600",
"bg-emerald-600",
"bg-amber-600",
"bg-rose-600",
"bg-teal-600",
"bg-indigo-600",
]
const getColor = (email: string) => {
let hash = 0
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash)
}
return pillColors[Math.abs(hash) % pillColors.length]
}
return (
<div className="relative" ref={containerRef}>
<div
className="flex min-h-[32px] cursor-text flex-wrap items-center gap-1 px-3 py-1"
onClick={() => {
inputRef.current?.focus()
onActivate?.()
}}
>
<span className="shrink-0 text-sm text-[#5f6368] select-none">{label}</span>
{contacts.map((c) => (
<span
key={c.email}
className="inline-flex items-center gap-1 rounded-full bg-[#e8eaed] py-0.5 pl-0.5 pr-2 text-sm text-[#3c4043] hover:bg-[#d2d5d9] transition-colors"
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold text-white",
getColor(c.email)
)}
>
{getInitials(c.name)}
</span>
<span className="max-w-[150px] truncate text-sm">
{c.name === c.email ? c.email : c.name}
</span>
<button
type="button"
onClick={() => removeContact(c.email)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-black/10"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setShowSuggestions(true)
}}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowSuggestions(true)
onActivate?.()
}}
onBlur={() => {
setTimeout(() => {
setShowSuggestions(false)
if (inputValue.trim()) tryAddRawEmail(inputValue)
}, 200)
}}
placeholder={contacts.length === 0 ? placeholder : undefined}
className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-[#202124] outline-none placeholder:text-[#80868b]"
/>
</div>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[240px] overflow-y-auto rounded-lg border border-[#dadce0] bg-white py-1 shadow-lg">
{suggestions.map((s, idx) => (
<button
key={s.email}
type="button"
onMouseDown={(e) => {
e.preventDefault()
addContact(s)
}}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors",
idx === selectedSuggestionIdx
? "bg-[#e8f0fe]"
: "hover:bg-[#f1f3f4]"
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white",
getColor(s.email)
)}
>
{getInitials(s.name)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-[#202124]">{s.name}</div>
<div className="truncate text-xs text-[#5f6368]">{s.email}</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
function AlignmentDropdown({
editor,
btnClass,
activeClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
activeClass: string
}) {
const currentIcon = editor.isActive({ textAlign: "center" })
? AlignCenter
: editor.isActive({ textAlign: "right" })
? AlignRight
: editor.isActive({ textAlign: "justify" })
? AlignJustify
: AlignLeft
const CurrentIcon = currentIcon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(btnClass, "w-auto gap-0.5 px-1")}
title="Alignement"
>
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")}
>
<AlignLeft className="h-4 w-4" /> Aligner à gauche
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("center").run()}
className={cn(editor.isActive({ textAlign: "center" }) && "bg-[#e8eaed]")}
>
<AlignCenter className="h-4 w-4" /> Centrer
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("right").run()}
className={cn(editor.isActive({ textAlign: "right" }) && "bg-[#e8eaed]")}
>
<AlignRight className="h-4 w-4" /> Aligner à droite
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
className={cn(editor.isActive({ textAlign: "justify" }) && "bg-[#e8eaed]")}
>
<AlignJustify className="h-4 w-4" /> Justifier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const FONT_FAMILIES = [
{ label: "Sans Serif", value: "sans-serif" },
{ label: "Serif", value: "serif" },
{ label: "Monospace", value: "monospace" },
{ label: "Cursive", value: "cursive" },
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
{ label: "Garamond", value: "Garamond, serif" },
{ label: "Georgia", value: "Georgia, serif" },
{ label: "Impact", value: "Impact, sans-serif" },
{ label: "Tahoma", value: "Tahoma, sans-serif" },
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
{ label: "Verdana", value: "Verdana, sans-serif" },
]
const FONT_SIZES = [
{ label: "Très petit", value: "10px" },
{ label: "Petit", value: "13px" },
{ label: "Normal", value: "" },
{ label: "Grand", value: "18px" },
{ label: "Très grand", value: "24px" },
{ label: "Énorme", value: "32px" },
]
const TEXT_COLORS = [
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
]
function FontDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
<CaseSensitive className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
>
{FONT_FAMILIES.map((f) => (
<DropdownMenuItem
key={f.value}
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
style={{ fontFamily: f.value }}
className={cn(
editor.isActive("textStyle", { fontFamily: f.value }) && "bg-[#e8eaed]"
)}
>
{f.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function FontSizeDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
<ALargeSmall className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => (
<DropdownMenuItem
key={s.label}
onSelect={() => {
if (s.value) {
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
} else {
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
}
}}
style={s.value ? { fontSize: s.value } : undefined}
className={cn(
s.value && editor.isActive("textStyle", { fontSize: s.value }) && "bg-[#e8eaed]"
)}
>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function ColorDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
const [tab, setTab] = useState<"text" | "bg">("text")
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
<Palette className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2 flex gap-1 border-b border-[#eef0f2] pb-2">
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "text" ? "bg-[#e8eaed] text-[#202124]" : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("text")}
>
Couleur du texte
</button>
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "bg" ? "bg-[#e8eaed] text-[#202124]" : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("bg")}
>
Couleur de fond
</button>
</div>
<div className="grid grid-cols-8 gap-1">
{TEXT_COLORS.map((color) => (
<button
key={`${tab}-${color}`}
type="button"
className="h-6 w-6 rounded border border-[#dadce0] hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
title={color}
onClick={() => {
if (tab === "text") {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
}
}}
/>
))}
</div>
<button
type="button"
className="mt-2 w-full rounded px-2 py-1 text-xs text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
onClick={() => {
if (tab === "text") {
editor.chain().focus().unsetColor().run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
}
}}
>
Réinitialiser
</button>
</DropdownMenuContent>
</DropdownMenu>
)
}
function FormattingToolbar({
editor,
}: {
editor: Editor | null
}) {
if (!editor) return null
const btnClass =
"flex h-7 w-7 items-center justify-center rounded hover:bg-[#f1f3f4] text-[#5f6368] transition-colors disabled:opacity-40"
const activeClass = "bg-[#e8eaed] text-[#202124]"
const sep = <span className="mx-0.5 h-5 w-px bg-[#dadce0]" aria-hidden />
return (
<div className="flex flex-wrap items-center border-t border-[#eef0f2] bg-[#f8f9fa] px-1 py-1">
{/* Undo / Redo */}
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annuler"
>
<Undo className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Rétablir"
>
<Redo className="h-4 w-4" />
</button>
{sep}
{/* Font */}
<FontDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Font size */}
<FontSizeDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Bold, Italic, Underline, Colors */}
<button
type="button"
className={cn(btnClass, editor.isActive("bold") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
title="Gras"
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("italic") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
title="Italique"
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("underline") && activeClass)}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Souligné"
>
<UnderlineIcon className="h-4 w-4" />
</button>
<ColorDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
<button
type="button"
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Liste numérotée"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Liste à puces"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
}}
title="Désindenter"
>
<Outdent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
}}
title="Indenter"
>
<Indent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Supprimer la mise en forme"
>
<RemoveFormatting className="h-4 w-4" />
</button>
</div>
)
}
function EmojiButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const handleSelect = useCallback(
(emoji: { native: string }) => {
editor?.chain().focus().insertContent(emoji.native).run()
setOpen(false)
},
[editor]
)
if (!editor) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Insérer un emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-auto border-0 p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<EmojiPicker onSelect={handleSelect} />
</PopoverContent>
</Popover>
)
}
function LinkButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState("")
const [text, setText] = useState("")
if (!editor) return null
const isLinkActive = editor.isActive("link")
const handleToggle = () => {
if (isLinkActive) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
setOpen(true)
}
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
const { from, to, empty } = editor.state.selection
if (isLinkActive) {
const attrs = editor.getAttributes("link")
setUrl(attrs.href || "")
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
} else if (!empty) {
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
setUrl("")
} else {
setText("")
setUrl("")
}
}
setOpen(isOpen)
}
const handleInsert = () => {
if (!url.trim()) return
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
const { empty } = editor.state.selection
if (empty && !isLinkActive) {
const displayText = text.trim() || href
editor
.chain()
.focus()
.insertContent(`<a href="${href}">${displayText}</a>`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`<a href="${href}">${text.trim()}</a>`)
.run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run()
}
}
setOpen(false)
setUrl("")
setText("")
}
const handleRemoveLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
setOpen(false)
setUrl("")
setText("")
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (isLinkActive) {
e.preventDefault()
handleToggle()
}
}}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors",
isLinkActive && "bg-[#e8eaed] text-[#202124]"
)}
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
>
<LinkIcon className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-[340px] p-3", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-medium text-[#202124]">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-[#5f6368]">Texte à afficher</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Texte du lien"
className="h-8 rounded border border-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-[#5f6368]">URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleInsert()
}
}}
className="h-8 rounded border border-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]"
autoFocus
/>
</div>
<div className="flex items-center justify-between pt-1">
{isLinkActive ? (
<button
type="button"
onClick={handleRemoveLink}
className="text-sm text-[#d93025] hover:text-[#b3261e] transition-colors"
>
Supprimer le lien
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleInsert}
disabled={!url.trim()}
className="rounded bg-[#0b57d0] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#0842a0] disabled:opacity-50 transition-colors"
>
{isLinkActive ? "Modifier" : "Insérer"}
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
function SignatureButton({
editor,
compose,
}: {
editor: Editor | null
compose: ComposeState
}) {
const { updateCompose } = useComposeActions()
const replaceSignature = useCallback(
(sigId: string | null) => {
if (!editor) return
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
},
[editor, compose.id, updateCompose]
)
const toggleAutoInsert = useCallback(() => {
const newVal = !compose.autoInsertSignature
updateCompose(compose.id, { autoInsertSignature: newVal })
if (!newVal) {
replaceSignature(null)
} else {
const sigId = compose.from.defaultSignatureId
if (sigId) replaceSignature(sigId)
}
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
if (!editor) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Insérer une signature"
>
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.autoInsertSignature && <span className="text-xs"></span>}
</span>
Insérer automatiquement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && "bg-[#e8eaed]")}
>
<span className="flex h-4 w-4 items-center justify-center">
{!compose.signatureId && <span className="text-xs"></span>}
</span>
Aucune signature
</DropdownMenuItem>
{SIGNATURES.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && "bg-[#e8eaed]")}
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.signatureId === sig.id && <span className="text-xs"></span>}
</span>
{sig.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface ComposeRecipientFieldsProps {
compose: ComposeState
isInline: boolean
showFromField: boolean
updateCompose: (id: string, patch: Partial<ComposeState>) => void
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
clearFocusToMount: () => void
subjectInputRef: React.RefObject<HTMLInputElement | null>
onRecipientsActivate: () => void
}
function ComposeRecipientFields({
compose,
isInline,
showFromField,
updateCompose,
handleIdentityChange,
clearFocusToMount,
subjectInputRef,
onRecipientsActivate,
}: ComposeRecipientFieldsProps) {
const dockNewMessageTabOrder =
!isInline && !compose.threadEmailId && !compose.threadKind
const forwardDockSkipSubjectTab =
!isInline && compose.threadKind === "forward"
return (
<>
{showFromField && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-3 py-1.5">
<span className="shrink-0 text-sm text-[#5f6368]">De</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 items-center gap-1 rounded px-1 py-0.5 text-sm text-[#202124] hover:bg-[#f1f3f4]"
>
<span className="min-w-0 truncate font-medium">{compose.from.name}</span>
<span className="min-w-0 shrink truncate text-[#5f6368]">
&lt;{compose.from.email}&gt;
</span>
<ChevronDown className="h-3 w-3 shrink-0 text-[#5f6368]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{DEFAULT_IDENTITIES.map((id) => (
<DropdownMenuItem
key={id.email}
onSelect={() => handleIdentityChange(id)}
>
<div className="flex flex-col">
<span className="font-medium">{id.name}</span>
<span className="text-xs text-[#5f6368]">
{id.email}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{showFromField && !isInline && (
<div className="ml-3 border-b border-[#eef0f2]" />
)}
<div className="relative flex items-start">
<div className="min-w-0 flex-1">
<RecipientField
label={showFromField || compose.to.length > 0 ? "À" : "Destinataires"}
contacts={compose.to}
onChange={(to) => updateCompose(compose.id, { to })}
onActivate={onRecipientsActivate}
autoFocus={Boolean(compose.focusToOnMount)}
onAutoFocusDone={clearFocusToMount}
/>
</div>
{showFromField && (!compose.showCc || !compose.showBcc) && (
<div className="flex shrink-0 items-center gap-1 px-2 py-1.5">
{!compose.showCc && (
<button
type="button"
tabIndex={dockNewMessageTabOrder ? -1 : undefined}
onClick={() => updateCompose(compose.id, { showCc: true })}
className="text-sm text-[#5f6368] hover:text-[#202124] hover:underline"
>
Cc
</button>
)}
{!compose.showBcc && (
<button
type="button"
tabIndex={dockNewMessageTabOrder ? -1 : undefined}
onClick={() => updateCompose(compose.id, { showBcc: true })}
className="text-sm text-[#5f6368] hover:text-[#202124] hover:underline"
>
Cci
</button>
)}
</div>
)}
</div>
{!isInline && <div className="ml-3 border-b border-[#eef0f2]" />}
{compose.showCc && (
<>
<RecipientField
label="Cc"
contacts={compose.cc}
onChange={(cc) => updateCompose(compose.id, { cc })}
/>
{!isInline && <div className="ml-3 border-b border-[#eef0f2]" />}
</>
)}
{compose.showBcc && (
<>
<RecipientField
label="Cci"
contacts={compose.bcc}
onChange={(bcc) => updateCompose(compose.id, { bcc })}
/>
{!isInline && <div className="ml-3 border-b border-[#eef0f2]" />}
</>
)}
{!isInline && (
<>
<input
ref={subjectInputRef}
type="text"
value={compose.subject}
onChange={(e) =>
updateCompose(compose.id, { subject: e.target.value })
}
placeholder="Objet"
tabIndex={forwardDockSkipSubjectTab ? -1 : undefined}
className="h-8 w-full border-none bg-transparent px-3 text-sm text-[#202124] outline-none placeholder:text-[#80868b]"
/>
<div className="ml-3 border-b border-[#eef0f2]" />
</>
)}
</>
)
}
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-[#202124] 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
className={cn(
"relative flex flex-col overflow-hidden bg-white",
isInline
? "min-h-[360px] w-full rounded-xl border border-[#dadce0] 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 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-blue-50/90 border-2 border-dashed border-[#1a73e8]">
<div className="text-center">
<Paperclip className="mx-auto h-8 w-8 text-[#1a73e8]" />
<p className="mt-2 text-sm font-medium text-[#1a73e8]">Déposer les fichiers ici</p>
</div>
</div>
)}
{isInline ? (
<div ref={inlineRecipientShellRef} className="flex shrink-0 flex-col">
<div
className="flex h-10 shrink-0 items-center gap-2 bg-white px-2"
title={
compose.threading
? `In-Reply-To: ${compose.threading.inReplyTo}\nReferences: ${compose.threading.references.join(" ")}`
: undefined
}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-0.5 rounded-full px-1.5 text-[#5f6368] transition-colors hover:bg-[#f1f3f4]"
title="Type de message"
>
<ThreadKindIcon className="h-[18px] w-[18px] shrink-0" strokeWidth={1.75} />
<ChevronDown className="h-4 w-4 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[260px]", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => openInlinePreset("reply")}
>
<Reply className="mr-2 h-4 w-4 shrink-0 text-[#5f6368]" strokeWidth={1.75} />
Répondre
</DropdownMenuItem>
{showReplyAllInMenu ? (
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => openInlinePreset("replyAll")}
>
<ReplyAll className="mr-2 h-4 w-4 shrink-0 text-[#5f6368]" strokeWidth={1.75} />
Répondre à tous
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => openInlinePreset("forward")}
>
<Forward className="mr-2 h-4 w-4 shrink-0 text-[#5f6368]" strokeWidth={1.75} />
Transférer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openDockFromInline({ focusSubject: true })}>
<Pencil className="mr-2 h-4 w-4 shrink-0 text-[#5f6368]" strokeWidth={1.75} />
Modifier l&apos;objet
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openDockFromInline()}>
<SquareArrowOutUpRight className="mr-2 h-4 w-4 shrink-0 text-[#5f6368]" strokeWidth={1.75} />
Ouvrir une fenêtre de réponse
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1 py-1 text-left text-sm text-[#202124] hover:bg-[#f1f3f4]/70"
onClick={() => setRecipientsFocused(true)}
>
{recipientSummary}
</button>
{!recipientsFocused && (!compose.showCc || !compose.showBcc) ? (
<div className="flex shrink-0 items-center gap-2">
{!compose.showCc ? (
<button
type="button"
onClick={() => {
updateCompose(compose.id, { showCc: true })
setRecipientsFocused(true)
}}
className="text-sm text-[#5f6368] hover:text-[#202124] hover:underline"
>
Cc
</button>
) : null}
{!compose.showBcc ? (
<button
type="button"
onClick={() => {
updateCompose(compose.id, { showBcc: true })
setRecipientsFocused(true)
}}
className="text-sm text-[#5f6368] hover:text-[#202124] hover:underline"
>
Cci
</button>
) : null}
</div>
) : null}
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4]"
title="Ouvrir dans une fenêtre"
onClick={() => openDockFromInline()}
>
<SquareArrowOutUpRight className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</div>
<div
ref={fieldsRef}
className={cn(
"flex shrink-0 flex-col",
!recipientsFocused && "hidden"
)}
>
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
</div>
) : isXsSheet ? (
<div
className={cn(
"flex h-11 shrink-0 items-center border-b border-[#dadce0] bg-[#f2f6fc] px-3",
"pt-[max(_0.25rem,env(safe-area-inset-top))]"
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<button
type="button"
onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Fermer"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<>
{/* Title bar */}
<div
className="flex h-10 shrink-0 cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3"
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMinimize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Réduire"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
aria-hidden
>
<line x1="5" y1="17" x2="19" y2="17" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title={compose.maximized ? "Réduire la fenêtre" : "Plein écran"}
>
{compose.maximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Fermer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</>
)}
{!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>
{/* Attachments */}
{compose.attachments.length > 0 && (
<div className="flex shrink-0 flex-col gap-1 border-t border-[#eef0f2] px-3 py-2 max-h-[120px] overflow-y-auto">
{compose.attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-2 rounded-lg border border-[#dadce0] bg-[#f8f9fa] px-3 py-1.5"
>
{att.type.startsWith("image/") ? (
<ImageIcon className="h-4 w-4 shrink-0 text-[#1a73e8]" />
) : (
<Paperclip className="h-4 w-4 shrink-0 text-[#5f6368]" />
)}
<span className="min-w-0 flex-1 truncate text-sm text-[#3c4043]">
{att.name}
</span>
<span className="shrink-0 text-xs text-[#80868b]">
{att.size < 1024
? `${att.size} o`
: att.size < 1048576
? `${(att.size / 1024).toFixed(1)} Ko`
: `${(att.size / 1048576).toFixed(1)} Mo`}
</span>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-black/10"
title="Supprimer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Formatting toolbar (toggle) */}
{showFormatting && <FormattingToolbar editor={editor} />}
{/* Bottom toolbar */}
<div className="flex shrink-0 items-center gap-1 border-t border-[#dadce0] px-2 py-1.5">
{/* Send / save + dropdown */}
<div className="flex items-center">
{isEditingScheduled ? (
<>
<button
type="button"
onClick={() => void saveScheduledEdit()}
className="h-9 rounded-l-full bg-[#0b57d0] px-5 text-sm font-medium text-white hover:bg-[#0842a0] hover:shadow-md transition-all"
>
Enregistrer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-9 rounded-r-full border-l border-white/30 bg-[#0b57d0] px-1.5 text-white hover:bg-[#0842a0] transition-colors"
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
}}
>
<Send className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Envoyer maintenant
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Envoyer à 9h
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<button
type="button"
onClick={handleSend}
className="h-9 rounded-l-full bg-[#0b57d0] px-5 text-sm font-medium text-white hover:bg-[#0842a0] hover:shadow-md transition-all"
>
Envoyer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-9 rounded-r-full border-l border-white/30 bg-[#0b57d0] px-1.5 text-white hover:bg-[#0842a0] transition-colors"
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-[#5f6368]" />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-[#5f6368]" />
Envoyer à 9h
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
<Clock className="h-4 w-4 text-[#5f6368]" />
Programmer l&apos;envoi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Toolbar icons */}
<div className="flex items-center gap-0.5 ml-1">
<button
type="button"
onClick={() => setShowFormatting(!showFormatting)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors",
showFormatting && "bg-[#e8eaed] text-[#202124]"
)}
title="Options de mise en forme"
>
<Type className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Joindre des fichiers"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-[18px] w-[18px]" />
</button>
<LinkButton editor={editor} />
<EmojiButton editor={editor} />
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Insérer des fichiers avec Google Drive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Insérer une photo"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Activer le mode confidentiel"
>
<Lock className="h-[18px] w-[18px]" />
</button>
<SignatureButton editor={editor} compose={compose} />
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Plus d'options"
>
<MoreVertical className="h-[18px] w-[18px]" />
</button>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:bg-[#f1f3f4] transition-colors"
title="Supprimer le brouillon"
>
<Trash2 className="h-[18px] w-[18px]" />
</button>
</div>
</div>
)
if (compose.minimized && !isInline && !isXsSheet) {
return (
<div
className="flex h-9 w-[280px] cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3 shadow-lg transition-shadow hover:shadow-xl"
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
>
<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>
))}
</>
)
}