email compose
This commit is contained in:
parent
5cb49f4cb1
commit
2af0724938
4 changed files with 967 additions and 181 deletions
206
frontend/src/components/compose/RecipientField.jsx
Normal file
206
frontend/src/components/compose/RecipientField.jsx
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue