ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
Recipient suggestions: - the words typed match in any order, each one the start of a word in the name, a nickname, the organization or the address: "jane smi" finds "Smith, Jane", and "globex" finds the people at Globex; - someone written to lately ranks a little above an equal match; - an address already in To, Cc or Bcc is no longer offered in the other two fields. Pasting: - a single web or mailto address pasted over selected words makes those words the link, instead of replacing them with the address; - a pasted or dropped image over 10 MB goes in as an attachment rather than inline, where it would swell every reply. No new strings.
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent } from "react";
|
|
import { X } from "lucide-react";
|
|
import type { EmailAddress } from "@/jmap/types";
|
|
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
|
import { useContacts, type Suggestion } from "@/store/contacts";
|
|
import { Avatar } from "@/ui/misc";
|
|
import { t as translate } from "@/lib/i18n";
|
|
|
|
interface Props {
|
|
value: EmailAddress[];
|
|
onChange: (v: EmailAddress[]) => void;
|
|
placeholder?: string;
|
|
autoFocus?: boolean;
|
|
id?: string;
|
|
/** Addresses already in the message's other recipient fields, not to be suggested again. */
|
|
exclude?: EmailAddress[];
|
|
}
|
|
|
|
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
|
|
const [text, setText] = useState("");
|
|
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
|
const [active, setActive] = useState(0);
|
|
const [open, setOpen] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const suggest = useContacts((s) => s.suggest);
|
|
const reqId = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const q = text.trim();
|
|
if (!q) {
|
|
setSugg([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
const id = ++reqId.current;
|
|
const t = window.setTimeout(() => {
|
|
void suggest(q).then((list) => {
|
|
if (id !== reqId.current) return;
|
|
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
|
|
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
|
setSugg(filtered);
|
|
setActive(0);
|
|
setOpen(filtered.length > 0);
|
|
});
|
|
}, 120);
|
|
return () => window.clearTimeout(t);
|
|
}, [text, suggest, value, exclude]);
|
|
|
|
const commit = (raw?: string) => {
|
|
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
|
if (!s) return;
|
|
const parsed = parseAddressList(s);
|
|
if (!parsed.length) return;
|
|
onChange([...value, ...parsed.filter((p) => !value.some((v) => v.email.toLowerCase() === p.email.toLowerCase()))]);
|
|
setText("");
|
|
setOpen(false);
|
|
};
|
|
|
|
const pick = (s: Suggestion) => {
|
|
onChange([...value, { name: s.name, email: s.email }]);
|
|
setText("");
|
|
setOpen(false);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (open && sugg.length) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActive((a) => (a + 1) % sugg.length);
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActive((a) => (a - 1 + sugg.length) % sugg.length);
|
|
return;
|
|
}
|
|
if (e.key === "Enter" || e.key === "Tab") {
|
|
if (sugg[active]) {
|
|
e.preventDefault();
|
|
pick(sugg[active]!);
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === "Escape") {
|
|
setOpen(false);
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
|
if (text.trim()) {
|
|
e.preventDefault();
|
|
commit();
|
|
} else if (e.key === "Enter") e.preventDefault();
|
|
} else if (e.key === "Tab" && text.trim()) {
|
|
commit();
|
|
} else if (e.key === "Backspace" && !text && value.length) {
|
|
const last = value[value.length - 1]!;
|
|
onChange(value.slice(0, -1));
|
|
setText(last.name ? `${last.name} <${last.email}>` : last.email);
|
|
}
|
|
};
|
|
|
|
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
|
const t = e.clipboardData.getData("text");
|
|
if (t && /[,;\n]|<.+@.+>/.test(t)) {
|
|
e.preventDefault();
|
|
commit(text + t);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="recipients" onClick={() => inputRef.current?.focus()}>
|
|
{value.map((a, i) => (
|
|
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
|
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
|
<button type="button" className="chip-x" aria-label={translate("Remove {address}", { address: a.email })} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
|
<X size={12} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<input
|
|
id={id}
|
|
ref={inputRef}
|
|
value={text}
|
|
placeholder={value.length ? "" : placeholder}
|
|
autoFocus={autoFocus}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={onKey}
|
|
onPaste={onPaste}
|
|
onBlur={() => { window.setTimeout(() => { setOpen(false); if (text.trim()) commit(); }, 150); }}
|
|
onFocus={() => sugg.length && setOpen(true)}
|
|
autoComplete="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
aria-autocomplete="list"
|
|
aria-expanded={open}
|
|
/>
|
|
{open && (
|
|
<div className="suggest-list" role="listbox">
|
|
{sugg.map((s, i) => (
|
|
<div key={s.email} className={`suggest-item ${i === active ? "active" : ""}`} role="option" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(s); }} onMouseEnter={() => setActive(i)}>
|
|
<Avatar who={s} size="sm" />
|
|
<div className="col" style={{ minWidth: 0 }}>
|
|
<span className="s-name truncate">{s.name ?? s.email}</span>
|
|
{s.name && <span className="s-email truncate">{s.email}</span>}
|
|
</div>
|
|
<span className="s-src">{s.source === "gal" ? "Directory" : s.source === "recent" ? "Recent" : ""}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|