2335 lines
75 KiB
TypeScript
2335 lines
75 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useState,
|
|
useRef,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useCallback,
|
|
useMemo,
|
|
lazy,
|
|
Suspense,
|
|
} from "react"
|
|
import { useIsXs } from "@/hooks/use-xs"
|
|
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-medium 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-medium 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]">
|
|
<{compose.from.email}>
|
|
</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
|
|
? "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'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'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>
|
|
))}
|
|
</>
|
|
)
|
|
}
|