import { useT } from "@agent-native/core/client/i18n"; import type { Alias } from "@shared/types"; import { IconX, IconUsersGroup, IconPencil, IconArrowsDiagonal, IconPlus, } from "@tabler/icons-react"; import { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo, } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "react-router"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useAliases, useCreateAlias } from "@/hooks/use-aliases"; import { useContacts, type Contact } from "@/hooks/use-emails"; import { isAliasToken, aliasIdFromToken, ALIAS_PREFIX, } from "@/lib/alias-utils"; import { cn } from "@/lib/utils"; /** Which header field a RecipientInput represents — used for cross-field drag. */ export type RecipientField = "to" | "cc" | "bcc"; /** DataTransfer MIME used when dragging a recipient chip between To/Cc/Bcc. */ const RECIPIENT_DRAG_MIME = "application/x-mail-recipient"; interface RecipientInputProps { value: string; onChange: (value: string) => void; placeholder?: string; autoFocus?: boolean; /** Field identity; enables dragging chips between To/Cc/Bcc when paired with onMoveRecipient. */ field?: RecipientField; /** Move a recipient token from one field to another (handled by the parent that owns all fields). */ onMoveRecipient?: ( value: string, from: RecipientField, to: RecipientField, ) => void; } export function parseRecipients(value: string): string[] { return value .split(",") .map((s) => s.trim()) .filter(Boolean); } export function serializeRecipients(recipients: string[]): string { return recipients.join(", "); } // Permissive single-address check — good enough to decide whether a typed/pasted // token should auto-lock into a chip on blur. Send-time validation is authoritative. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function isValidEmail(value: string): boolean { return EMAIL_RE.test(value.trim()); } /** * Splits a pasted blob of recipients (comma/semicolon/newline separated) into the * valid emails to add (deduped, case-insensitively, against `existing` and each * other) plus any non-email leftover to keep in the input. Returns `null` when the * paste should not be intercepted (single token, or nothing email-like) so the * caller can fall through to normal paste behavior. */ export function extractPastedRecipients( text: string, existing: string[], ): { added: string[]; leftover: string } | null { if (!text || !/[,;\n]/.test(text)) return null; const tokens = text .split(/[,;\n]+/) .map((t) => t.trim()) .filter(Boolean); const emails = tokens.filter(isValidEmail); if (emails.length === 0) return null; const seen = new Set(existing.map((r) => r.toLowerCase())); const added: string[] = []; for (const email of emails) { const key = email.toLowerCase(); if (seen.has(key)) continue; seen.add(key); added.push(email); } const leftover = tokens.filter((t) => !isValidEmail(t)).join(", "); return { added, leftover }; } /** * Computes the new comma-separated values for two recipient fields when a token is * moved from one to the other (drag between To/Cc/Bcc). Removes `value` from the * source and appends it to the target unless already present (case-insensitive). */ export function computeRecipientMove( fromRaw: string, toRaw: string, value: string, ): { from: string; to: string } { const fromList = parseRecipients(fromRaw).filter((r) => r !== value); const toList = parseRecipients(toRaw); const alreadyThere = toList.some( (r) => r.toLowerCase() === value.toLowerCase(), ); return { from: serializeRecipients(fromList), to: serializeRecipients(alreadyThere ? toList : [...toList, value]), }; } // ─── AliasPopover ───────────────────────────────────────────────────────────── interface AliasPopoverProps { alias: Alias; anchorEl: HTMLElement; onClose: () => void; onExpand: () => void; } function AliasPopover({ alias, anchorEl, onClose, onExpand, }: AliasPopoverProps) { const t = useT(); const navigate = useNavigate(); const panelRef = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0 }); useLayoutEffect(() => { const rect = anchorEl.getBoundingClientRect(); setPos({ top: rect.bottom + 6, left: rect.left, }); }, [anchorEl]); // Close on outside click useEffect(() => { const handleClick = (e: MouseEvent) => { if ( panelRef.current && !panelRef.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node) ) { onClose(); } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [anchorEl, onClose]); const handleEdit = () => { navigate(`/settings?alias=${alias.id}`); onClose(); }; const handleExpand = () => { onExpand(); onClose(); }; return createPortal(
{/* Header */}
{alias.name} {t("mail.recipients.recipientCount", { count: alias.emails.length, })} {t("mail.recipients.editAlias")} {t("mail.recipients.expandToIndividualEmails")}
{/* Email list */}
{alias.emails.map((email) => (
{email[0]}
{email}
))}
, document.body, ); } // ─── SaveAliasModal ─────────────────────────────────────────────────────────── interface SaveAliasModalProps { emails: string[]; onClose: () => void; } function SaveAliasModal({ emails, onClose }: SaveAliasModalProps) { const t = useT(); const [name, setName] = useState(""); const createAlias = useCreateAlias(); const handleSave = async () => { if (!name.trim()) return; await createAlias.mutateAsync({ name: name.trim(), emails }); onClose(); }; return ( !open && onClose()}> {t("mail.recipients.saveAsAlias")} {t("mail.recipients.createReusableGroup", { count: emails.length, })} setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleSave(); e.stopPropagation(); }} placeholder={t("mail.recipients.aliasName")} /> ); } // ─── RecipientInput ─────────────────────────────────────────────────────────── export function RecipientInput({ value, onChange, placeholder, autoFocus, field, onMoveRecipient, }: RecipientInputProps) { const t = useT(); const [inputValue, setInputValue] = useState(""); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [isDropTarget, setIsDropTarget] = useState(false); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const [popoverAlias, setPopoverAlias] = useState<{ alias: Alias; anchorEl: HTMLElement; } | null>(null); const [showSaveModal, setShowSaveModal] = useState(false); const inputRef = useRef(null); const containerRef = useRef(null); const dropdownRef = useRef(null); const { data: contacts = [] } = useContacts(); const { data: aliases = [] } = useAliases(); const recipients = useMemo(() => parseRecipients(value), [value]); const query = inputValue.toLowerCase().trim(); const filteredAliases = useMemo( () => query ? aliases.filter((a) => { const alreadyAdded = recipients.includes(`${ALIAS_PREFIX}${a.id}`); return !alreadyAdded && a.name.toLowerCase().includes(query); }) : [], [query, aliases, value], ); // Contacts come pre-sorted by frequency from the server (SQL-tracked send counts) const filteredContacts = useMemo( () => query ? contacts.filter((c) => { const alreadyAdded = recipients.some( (r) => r.toLowerCase() === c.email.toLowerCase(), ); return ( !alreadyAdded && (c.name.toLowerCase().includes(query) || c.email.toLowerCase().includes(query)) ); }) : [], [query, contacts, value], ); // Combined for keyboard nav — aliases first (sliced to match dropdown rendering) const allSuggestions = useMemo((): Array< { type: "alias"; item: Alias } | { type: "contact"; item: Contact } > => { const aliasSlice = filteredAliases.slice(0, 4); const contactSlice = filteredContacts.slice( 0, 8 - Math.min(filteredAliases.length, 4), ); return [ ...aliasSlice.map((a) => ({ type: "alias" as const, item: a })), ...contactSlice.map((c) => ({ type: "contact" as const, item: c })), ]; }, [filteredAliases, filteredContacts]); const hasSuggestions = allSuggestions.length > 0; const addRecipient = useCallback( (emailOrContact: string | Contact) => { const email = typeof emailOrContact === "string" ? emailOrContact.trim() : emailOrContact.email; if (!email) return; const alreadyAdded = recipients.some( (r) => r.toLowerCase() === email.toLowerCase(), ); if (alreadyAdded) { setInputValue(""); setShowSuggestions(false); return; } const updated = [...recipients, email]; onChange(serializeRecipients(updated)); setInputValue(""); setShowSuggestions(false); setSelectedIndex(0); }, [recipients, onChange], ); const addAliasToken = useCallback( (alias: Alias) => { const token = `${ALIAS_PREFIX}${alias.id}`; const alreadyAdded = recipients.includes(token); if (alreadyAdded) { setInputValue(""); setShowSuggestions(false); return; } const updated = [...recipients, token]; onChange(serializeRecipients(updated)); setInputValue(""); setShowSuggestions(false); setSelectedIndex(0); }, [recipients, onChange], ); const removeRecipient = useCallback( (index: number) => { const updated = recipients.filter((_, i) => i !== index); onChange(serializeRecipients(updated)); }, [recipients, onChange], ); const expandAlias = useCallback( (index: number) => { const token = recipients[index]; const id = aliasIdFromToken(token); const alias = aliases.find((a) => a.id === id); if (!alias) return; const before = recipients.slice(0, index); const after = recipients.slice(index + 1); const expanded = [...new Set(alias.emails)]; onChange(serializeRecipients([...before, ...expanded, ...after])); }, [recipients, aliases, onChange], ); // Lock a pasted/typed-but-unconfirmed address into a chip when the field loses // focus, so a bare paste (no Enter/Tab/comma) isn't silently dropped on send. const handleBlur = () => { const trimmed = inputValue.trim(); if (trimmed && isValidEmail(trimmed)) { addRecipient(trimmed); } }; // Pasting several addresses at once (comma/semicolon/newline separated) splits // them into chips. A single token with no delimiter falls through to the normal // paste so it lands in the input and locks in on blur. const handlePaste = (e: React.ClipboardEvent) => { const result = extractPastedRecipients( e.clipboardData.getData("text"), recipients, ); if (!result) return; // single token / nothing email-like → normal paste e.preventDefault(); if (result.added.length > 0) { onChange(serializeRecipients([...recipients, ...result.added])); } // Preserve any non-email leftovers (e.g. a half-typed address) in the input. setInputValue(result.leftover); setShowSuggestions(false); }; // ── Cross-field drag (To ⇄ Cc ⇄ Bcc), Superhuman-style ────────────────────── const canDrag = !!field && !!onMoveRecipient; const handleChipDragStart = ( e: React.DragEvent, recipientValue: string, ) => { if (!field) return; e.dataTransfer.setData( RECIPIENT_DRAG_MIME, JSON.stringify({ value: recipientValue, from: field }), ); e.dataTransfer.effectAllowed = "move"; }; const handleContainerDragOver = (e: React.DragEvent) => { if (!canDrag) return; if (!Array.from(e.dataTransfer.types).includes(RECIPIENT_DRAG_MIME)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "move"; if (!isDropTarget) setIsDropTarget(true); }; const handleContainerDragLeave = (e: React.DragEvent) => { // Ignore moves between the container's own children. if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; setIsDropTarget(false); }; const handleContainerDrop = (e: React.DragEvent) => { if (!canDrag || !field || !onMoveRecipient) return; const raw = e.dataTransfer.getData(RECIPIENT_DRAG_MIME); if (!raw) return; e.preventDefault(); e.stopPropagation(); setIsDropTarget(false); try { const { value: dropped, from } = JSON.parse(raw) as { value: string; from: RecipientField; }; if (!dropped || from === field) return; onMoveRecipient(dropped, from, field); } catch { // Malformed payload — ignore. } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === "Tab") { if (hasSuggestions && showSuggestions) { e.preventDefault(); const suggestion = allSuggestions[selectedIndex] ?? allSuggestions[0]; if (suggestion.type === "alias") { addAliasToken(suggestion.item); } else { addRecipient(suggestion.item); } } else if (inputValue.trim()) { e.preventDefault(); addRecipient(inputValue); } else if (e.key === "Tab") { return; } } else if (e.key === ",") { e.preventDefault(); if (inputValue.trim()) { addRecipient(inputValue); } } else if (e.key === "Backspace" && !inputValue && recipients.length > 0) { removeRecipient(recipients.length - 1); } else if (e.key === "ArrowDown" && showSuggestions) { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, allSuggestions.length - 1)); } else if (e.key === "ArrowUp" && showSuggestions) { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Escape" && showSuggestions) { e.stopPropagation(); setShowSuggestions(false); } }; // Position dropdown relative to container, rendered via portal useLayoutEffect(() => { if (showSuggestions && hasSuggestions && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: Math.max(rect.width, 280), }); } }, [showSuggestions, hasSuggestions, inputValue]); // Close suggestions on outside click useEffect(() => { const handleClick = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) && dropdownRef.current && !dropdownRef.current.contains(e.target as Node) ) { setShowSuggestions(false); } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); // Clamp selected index when filtered list changes (preserves position when possible) useEffect(() => { setSelectedIndex((prev) => Math.min(prev, allSuggestions.length - 1)); }, [allSuggestions.length]); const dropdown = showSuggestions && hasSuggestions ? createPortal(
{filteredAliases.slice(0, 4).map((alias, i) => ( ))} {filteredContacts .slice(0, 8 - Math.min(filteredAliases.length, 4)) .map((contact, i) => { const globalIndex = Math.min(filteredAliases.length, 4) + i; return ( ); })}
, document.body, ) : null; // Non-alias recipients for save-as-alias const nonAliasRecipients = recipients.filter((r) => !isAliasToken(r)); const canSaveAlias = nonAliasRecipients.length >= 2; return (
{recipients.map((r, i) => { if (isAliasToken(r)) { const id = aliasIdFromToken(r); const alias = aliases.find((a) => a.id === id); const displayName = alias?.name ?? id; const count = alias?.emails.length ?? 0; return ( handleChipDragStart(e, r) : undefined } className={cn( "flex items-center gap-0.5 rounded-md border border-indigo-500/35 bg-indigo-500/18 px-2 py-0.5 text-xs", canDrag && "cursor-grab active:cursor-grabbing", )} > ); } return ( handleChipDragStart(e, r) : undefined } className={cn( "flex items-center gap-0.5 rounded-md bg-accent px-2 py-0.5 text-xs text-accent-foreground", canDrag && "cursor-grab active:cursor-grabbing", )} > {r} ); })} { setInputValue(e.target.value); setShowSuggestions(true); }} onFocus={() => { if (inputValue.trim()) setShowSuggestions(true); }} onBlur={handleBlur} onPaste={handlePaste} onKeyDown={handleKeyDown} placeholder={recipients.length === 0 ? placeholder : ""} className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" autoFocus={autoFocus} /> {canSaveAlias && ( )}
{dropdown} {popoverAlias && ( setPopoverAlias(null)} onExpand={() => { const index = recipients.indexOf( `${ALIAS_PREFIX}${popoverAlias.alias.id}`, ); if (index !== -1) expandAlias(index); }} /> )} {showSaveModal && ( setShowSaveModal(false)} /> )}
); }