email compose

This commit is contained in:
debu-2 2026-10-05 21:48:56 +05:30
parent 5cb49f4cb1
commit 2af0724938
4 changed files with 967 additions and 181 deletions

View file

@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function parseAddressToken(token) {
const trimmed = token.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (match) {
return { name: match[1].trim(), email: match[2].trim().toLowerCase() };
}
if (EMAIL_RE.test(trimmed)) {
return { name: "", email: trimmed.toLowerCase() };
}
return { name: trimmed, email: "", invalid: true };
}
function recipientKey(recipient) {
return recipient.email || recipient.name;
}
function isExternal(email, orgDomain) {
if (!email || !orgDomain) return false;
const domain = email.split("@")[1]?.toLowerCase();
return Boolean(domain && domain !== orgDomain.toLowerCase());
}
export default function RecipientField({
label,
recipients,
onChange,
suggestions = [],
orgDomain,
onFocus,
autoFocus = false,
}) {
const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const inputRef = useRef(null);
const wrapRef = useRef(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
useEffect(() => {
function handleClick(event) {
if (!wrapRef.current?.contains(event.target)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const filtered = suggestions
.filter((item) => {
const query = input.trim().toLowerCase();
if (!query) return true;
const haystack = `${item.name} ${item.email}`.toLowerCase();
return haystack.includes(query);
})
.filter((item) => !recipients.some((r) => r.email === item.email))
.slice(0, 8);
function addRecipient(recipient) {
const normalized = {
name: recipient.name || "",
email: recipient.email?.toLowerCase() || "",
invalid: recipient.invalid || (recipient.email ? !EMAIL_RE.test(recipient.email) : true),
external: isExternal(recipient.email, orgDomain),
group: Boolean(recipient.group),
};
if (!normalized.email && !normalized.name) return;
if (recipients.some((r) => r.email && r.email === normalized.email)) return;
onChange([...recipients, normalized]);
setInput("");
setOpen(false);
setHighlight(0);
inputRef.current?.focus();
}
function commitInput() {
const parts = input.split(/[,;\n]/).map((part) => part.trim()).filter(Boolean);
if (!parts.length) return;
const next = [...recipients];
parts.forEach((part) => {
const parsed = parseAddressToken(part);
if (!parsed) return;
const entry = {
...parsed,
invalid: parsed.invalid || (parsed.email ? !EMAIL_RE.test(parsed.email) : true),
external: isExternal(parsed.email, orgDomain),
};
if (entry.email && next.some((r) => r.email === entry.email)) return;
next.push(entry);
});
onChange(next);
setInput("");
setOpen(false);
}
function removeAt(index) {
onChange(recipients.filter((_, i) => i !== index));
inputRef.current?.focus();
}
function handleKeyDown(event) {
if (event.key === "ArrowDown" && open && filtered.length) {
event.preventDefault();
setHighlight((h) => (h + 1) % filtered.length);
return;
}
if (event.key === "ArrowUp" && open && filtered.length) {
event.preventDefault();
setHighlight((h) => (h - 1 + filtered.length) % filtered.length);
return;
}
if (event.key === "Enter" || event.key === "Tab" || event.key === ",") {
if (open && filtered.length && event.key !== ",") {
event.preventDefault();
addRecipient(filtered[highlight]);
return;
}
if (input.trim()) {
event.preventDefault();
commitInput();
}
return;
}
if (event.key === "Backspace" && !input && recipients.length) {
removeAt(recipients.length - 1);
}
}
return (
<div className="compose-recipient-field" ref={wrapRef}>
<span className="compose-recipient-label">{label}</span>
<div className="compose-recipient-chips" onClick={() => inputRef.current?.focus()}>
{recipients.map((recipient, index) => (
<span
key={`${recipientKey(recipient)}-${index}`}
className={`compose-chip${recipient.invalid ? " invalid" : ""}${recipient.external ? " external" : ""}${recipient.group ? " group" : ""}`}
title={recipient.email}
>
<span className="compose-chip-label">
{recipient.name || recipient.email}
{recipient.external && <em>External</em>}
</span>
<button type="button" aria-label={`Remove ${recipient.email || recipient.name}`} onClick={() => removeAt(index)}>
<X size={12} />
</button>
</span>
))}
<input
ref={inputRef}
className="compose-recipient-input"
value={input}
onChange={(event) => {
setInput(event.target.value);
setOpen(true);
setHighlight(0);
}}
onFocus={() => {
setOpen(true);
onFocus?.();
}}
onKeyDown={handleKeyDown}
onBlur={() => {
if (input.trim()) commitInput();
}}
placeholder={recipients.length ? "" : "Recipients"}
autoComplete="off"
spellCheck={false}
/>
</div>
{open && filtered.length > 0 && (
<ul className="compose-recipient-suggestions" role="listbox">
{filtered.map((item, index) => (
<li key={item.email || item.name} role="option" aria-selected={index === highlight}>
<button
type="button"
className={index === highlight ? "active" : ""}
onMouseDown={(event) => {
event.preventDefault();
addRecipient(item);
}}
>
<strong>{item.name || item.email}</strong>
{item.name && <small>{item.email}</small>}
{item.group && <em>Group</em>}
{isExternal(item.email, orgDomain) && <em>External</em>}
</button>
</li>
))}
</ul>
)}
</div>
);
}
export function recipientsToPayload(recipients) {
return recipients
.filter((r) => r.email && EMAIL_RE.test(r.email))
.map((r) => (r.name ? `${r.name} <${r.email}>` : r.email));
}