416 lines
13 KiB
TypeScript
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">
|
|
<{compose.from.email}>
|
|
</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'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} />
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|