"use client" import { useState, useRef, useEffect, useCallback, useMemo, type RefObject, } from "react" import { ChevronDown, X } from "lucide-react" import { type ComposeState, type Contact, DEFAULT_IDENTITIES, MOCK_CONTACTS, } from "@/lib/compose-context" import { cn } from "@/lib/utils" import { MAIL_COMPOSE_CONTACT_PILL_CLASS, MAIL_COMPOSE_RECIPIENT_DIVIDER, MAIL_COMPOSE_SUGGESTION_HOVER, MAIL_COMPOSE_SUGGESTION_SELECTED, } from "@/lib/mail-chrome-classes" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { COMPOSE_PORTAL_Z, EMAIL_REGEX } from "./compose-shared" 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-foreground outline-none placeholder:text-muted-foreground" />
{showSuggestions && suggestions.length > 0 && (
{suggestions.map((s, idx) => ( ))}
)}
) } export interface ComposeRecipientFieldsProps { compose: ComposeState isInline: boolean showFromField: boolean updateCompose: (id: string, patch: Partial) => void handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void clearFocusToMount: () => void subjectInputRef: RefObject onRecipientsActivate: () => void } export 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-foreground outline-none placeholder:text-muted-foreground" />
)} ) }