ultisuite-client/components/gmail/compose/compose-recipients.tsx
2026-05-25 13:52:40 +02:00

416 lines
13 KiB
TypeScript

"use client"
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
type RefObject,
} from "react"
import { ChevronDown, X } from "lucide-react"
import {
type ComposeState,
type Contact,
type Identity,
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<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 select-none text-sm text-muted-foreground">{label}</span>
{contacts.map((c) => (
<span key={c.email} className={MAIL_COMPOSE_CONTACT_PILL_CLASS}>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold text-white",
getColor(c.email)
)}
>
{getInitials(c.name)}
</span>
<span className="max-w-[150px] truncate text-sm">
{c.name === c.email ? c.email : c.name}
</span>
<button
type="button"
onClick={() => removeContact(c.email)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-black/10"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setShowSuggestions(true)
}}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowSuggestions(true)
onActivate?.()
}}
onBlur={() => {
setTimeout(() => {
setShowSuggestions(false)
if (inputValue.trim()) tryAddRawEmail(inputValue)
}, 200)
}}
placeholder={contacts.length === 0 ? placeholder : undefined}
className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</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-border bg-popover py-1 text-popover-foreground 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
? MAIL_COMPOSE_SUGGESTION_SELECTED
: MAIL_COMPOSE_SUGGESTION_HOVER
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white",
getColor(s.email)
)}
>
{getInitials(s.name)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{s.name}</div>
<div className="truncate text-xs text-muted-foreground">{s.email}</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
export interface ComposeRecipientFieldsProps {
compose: ComposeState
isInline: boolean
showFromField: boolean
identities?: Identity[]
updateCompose: (id: string, patch: Partial<ComposeState>) => void
handleIdentityChange: (identity: Identity) => void
clearFocusToMount: () => void
subjectInputRef: RefObject<HTMLInputElement | null>
onRecipientsActivate: () => void
}
export function ComposeRecipientFields({
compose,
isInline,
showFromField,
identities = [],
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-muted-foreground">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-foreground hover:bg-accent"
>
<span className="min-w-0 truncate font-medium">{compose.from.name}</span>
<span className="min-w-0 shrink truncate text-muted-foreground">
&lt;{compose.from.email}&gt;
</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{identities.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-sm text-muted-foreground">
Aucune identité d&apos;envoi ajoutez un compte mail dans les réglages.
</span>
</DropdownMenuItem>
) : (
identities.map((id) => (
<DropdownMenuItem
key={id.id ?? id.email}
onSelect={() => handleIdentityChange(id)}
>
<div className="flex flex-col">
<span className="font-medium">{id.name}</span>
<span className="text-xs text-muted-foreground">{id.email}</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{showFromField && !isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
<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-muted-foreground hover:text-foreground hover:underline"
>
Cc
</button>
)}
{!compose.showBcc && (
<button
type="button"
tabIndex={dockNewMessageTabOrder ? -1 : undefined}
onClick={() => updateCompose(compose.id, { showBcc: true })}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Cci
</button>
)}
</div>
)}
</div>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
{compose.showCc && (
<>
<RecipientField
label="Cc"
contacts={compose.cc}
onChange={(cc) => updateCompose(compose.id, { cc })}
/>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
</>
)}
{compose.showBcc && (
<>
<RecipientField
label="Cci"
contacts={compose.bcc}
onChange={(bcc) => updateCompose(compose.id, { bcc })}
/>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
</>
)}
{!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-foreground outline-none placeholder:text-muted-foreground"
/>
<div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />
</>
)}
</>
)
}