535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useCallback } from "react"
|
|
import { type Editor } from "@tiptap/react"
|
|
import {
|
|
ChevronDown,
|
|
Paperclip,
|
|
Link as LinkIcon,
|
|
HardDrive,
|
|
Image as ImageIcon,
|
|
Lock,
|
|
PenTool,
|
|
MoreVertical,
|
|
Trash2,
|
|
Type,
|
|
Clock,
|
|
Send,
|
|
} from "lucide-react"
|
|
import {
|
|
type ComposeState,
|
|
useComposeActions,
|
|
} from "@/lib/compose-context"
|
|
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
|
import {
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
|
|
MAIL_COMPOSE_MENU_SELECTED_CLASS,
|
|
MAIL_COMPOSE_POPOVER_CLASS,
|
|
MAIL_COMPOSE_PRIMARY_SEND_BTN,
|
|
MAIL_MENU_SURFACE_CLASS,
|
|
} from "@/lib/mail-chrome-classes"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import { useMailSignaturesStore } from "@/lib/stores/mail-signatures-store"
|
|
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
|
import { ComposeEmojiButton } from "./compose-emoji-picker"
|
|
|
|
function ComposeLinkButton({
|
|
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(
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
|
|
)}
|
|
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]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="flex flex-col gap-2.5">
|
|
<div className="text-sm font-medium text-foreground">
|
|
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-muted-foreground">Texte à afficher</label>
|
|
<input
|
|
type="text"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder="Texte du lien"
|
|
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-muted-foreground">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-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-1">
|
|
{isLinkActive ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleRemoveLink}
|
|
className="text-sm text-destructive hover:text-destructive/90 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-muted-foreground hover:bg-accent transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleInsert}
|
|
disabled={!url.trim()}
|
|
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
{isLinkActive ? "Modifier" : "Insérer"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export function ComposeSignatureButton({
|
|
editor,
|
|
compose,
|
|
}: {
|
|
editor: Editor | null
|
|
compose: ComposeState
|
|
}) {
|
|
const signatures = useMailSignaturesStore((s) => s.signatures)
|
|
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={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer une signature"
|
|
>
|
|
<PenTool className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
side="top"
|
|
className={cn(MAIL_MENU_SURFACE_CLASS, "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 && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<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 && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
export interface ComposeBottomToolbarProps {
|
|
compose: ComposeState
|
|
editor: Editor | null
|
|
isEditingScheduled: boolean
|
|
showFormatting: boolean
|
|
sendMenuOpen: boolean
|
|
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
|
|
setSendMenuOpen: (v: boolean) => void
|
|
handleSend: () => void
|
|
saveScheduledEdit: () => void | Promise<void>
|
|
sendScheduledFromEditNow: () => void | Promise<void>
|
|
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
|
|
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
|
|
handleClose: () => void
|
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
|
imageInputRef: React.RefObject<HTMLInputElement | null>
|
|
}
|
|
|
|
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
|
|
const {
|
|
compose,
|
|
editor,
|
|
isEditingScheduled,
|
|
showFormatting,
|
|
sendMenuOpen,
|
|
setShowFormatting,
|
|
setSendMenuOpen,
|
|
handleSend,
|
|
saveScheduledEdit,
|
|
sendScheduledFromEditNow,
|
|
applyScheduledPlanAt,
|
|
submitScheduledSendAt,
|
|
handleClose,
|
|
fileInputRef,
|
|
imageInputRef,
|
|
} = props
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
|
|
{/* Send / save + dropdown */}
|
|
<div className="flex items-center">
|
|
{isEditingScheduled ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveScheduledEdit()}
|
|
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
Enregistrer
|
|
</button>
|
|
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
<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-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer maintenant
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
|
|
<Clock className="h-4 w-4 text-muted-foreground" 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-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer dans une heure
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void applyScheduledPlanAt(
|
|
getNextLocalWallClockDate(9, 0)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer à 9h
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={handleSend}
|
|
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
Envoyer
|
|
</button>
|
|
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
<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-muted-foreground" />
|
|
Envoyer dans une heure
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void submitScheduledSendAt(
|
|
getNextLocalWallClockDate(9, 0)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
Envoyer à 9h
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
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(
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
|
|
)}
|
|
title="Options de mise en forme"
|
|
>
|
|
<Type className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Joindre des fichiers"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Paperclip className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<ComposeLinkButton editor={editor} />
|
|
<ComposeEmojiButton editor={editor} />
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer des fichiers avec Google Drive"
|
|
>
|
|
<HardDrive className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer une photo"
|
|
onClick={() => imageInputRef.current?.click()}
|
|
>
|
|
<ImageIcon className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Activer le mode confidentiel"
|
|
>
|
|
<Lock className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<ComposeSignatureButton editor={editor} compose={compose} />
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Plus d'options"
|
|
>
|
|
<MoreVertical className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Supprimer le brouillon"
|
|
>
|
|
<Trash2 className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|