"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 (
Chargement…}>
)
}
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 = /
[\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 + `
`
}
/** 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
(null)
const containerRef = useRef(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 (
{
inputRef.current?.focus()
onActivate?.()
}}
>
{label}
{contacts.map((c) => (
{getInitials(c.name)}
{c.name === c.email ? c.email : c.name}
))}
{
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]"
/>
{showSuggestions && suggestions.length > 0 && (
{suggestions.map((s, idx) => (
))}
)}
)
}
function AlignmentDropdown({
editor,
btnClass,
activeClass,
}: {
editor: NonNullable>
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 (
editor.chain().focus().setTextAlign("left").run()}
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")}
>
Aligner à gauche
editor.chain().focus().setTextAlign("center").run()}
className={cn(editor.isActive({ textAlign: "center" }) && "bg-[#e8eaed]")}
>
Centrer
editor.chain().focus().setTextAlign("right").run()}
className={cn(editor.isActive({ textAlign: "right" }) && "bg-[#e8eaed]")}
>
Aligner à droite
editor.chain().focus().setTextAlign("justify").run()}
className={cn(editor.isActive({ textAlign: "justify" }) && "bg-[#e8eaed]")}
>
Justifier
)
}
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>
btnClass: string
}) {
return (
{FONT_FAMILIES.map((f) => (
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}
))}
)
}
function FontSizeDropdown({
editor,
btnClass,
}: {
editor: NonNullable>
btnClass: string
}) {
return (
{FONT_SIZES.map((s) => (
{
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}
))}
)
}
function ColorDropdown({
editor,
btnClass,
}: {
editor: NonNullable>
btnClass: string
}) {
const [tab, setTab] = useState<"text" | "bg">("text")
return (
e.preventDefault()}
>
{TEXT_COLORS.map((color) => (
)
}
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 =
return (
{/* Undo / Redo */}
{sep}
{/* Font */}
{sep}
{/* Font size */}
{sep}
{/* Bold, Italic, Underline, Colors */}
{sep}
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
)
}
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 (
e.preventDefault()}
>
)
}
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(`${displayText}`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`${text.trim()}`)
.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 (
e.preventDefault()}
>
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
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]"
/>
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
/>
{isLinkActive ? (
) : (
)}
)
}
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 (
{
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
{compose.autoInsertSignature && ✓}
Insérer automatiquement
replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && "bg-[#e8eaed]")}
>
{!compose.signatureId && ✓}
Aucune signature
{SIGNATURES.map((sig) => (
replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && "bg-[#e8eaed]")}
>
{compose.signatureId === sig.id && ✓}
{sig.name}
))}
)
}
interface ComposeRecipientFieldsProps {
compose: ComposeState
isInline: boolean
showFromField: boolean
updateCompose: (id: string, patch: Partial) => void
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
clearFocusToMount: () => void
subjectInputRef: React.RefObject
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 && (
De
{DEFAULT_IDENTITIES.map((id) => (
handleIdentityChange(id)}
>
{id.name}
{id.email}
))}
)}
{showFromField && !isInline && (
)}
0 ? "À" : "Destinataires"}
contacts={compose.to}
onChange={(to) => updateCompose(compose.id, { to })}
onActivate={onRecipientsActivate}
autoFocus={Boolean(compose.focusToOnMount)}
onAutoFocusDone={clearFocusToMount}
/>
{showFromField && (!compose.showCc || !compose.showBcc) && (
{!compose.showCc && (
)}
{!compose.showBcc && (
)}
)}
{!isInline && }
{compose.showCc && (
<>
updateCompose(compose.id, { cc })}
/>
{!isInline && }
>
)}
{compose.showBcc && (
<>
updateCompose(compose.id, { bcc })}
/>
{!isInline && }
>
)}
{!isInline && (
<>
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]"
/>
>
)}
>
)
}
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(null)
const inlineRecipientShellRef = useRef(null)
const subjectInputRef = useRef(null)
const fileInputRef = useRef(null)
const imageInputRef = useRef(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>/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(/