Choose recipients from the address books
Addressing a message worked only if you already knew the name you were half-way through typing. Autocomplete answers "finish this for me"; there was no answer to "who is there?", which is the question someone has when they open a compose window and want the person from the team list whose surname they cannot summon. The To row now opens the address books -- from a button beside Cc and Bcc, where someone thinking about recipients is already looking, and from the To label itself for anyone who tries that first. Search across every book or narrow to one, tick as many people as the message needs, and send them to To, Cc or Bcc. Picking for a field that is hidden opens it, since a Bcc dropped somewhere invisible is worse than no Bcc. Every address is its own row rather than every person. Somebody with a work address and a personal one is a choice the writer has to make, and a picker that listed the card and quietly took the first address would be making it for them. Shared books are in it on the same footing as the reader's own -- that being the point of having added them -- with the account named on each row, so it is never a mystery whose list a name came from. Books that have not been added contribute nothing, the same rule the To field already follows. Verified against the mock: the picker lists the reader's contacts and the shared book's, each row naming its source; ticking one of each and choosing Cc opens the Cc row with both in it.
This commit is contained in:
@@ -1030,3 +1030,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||
|
||||
/* The composer's To label doubles as the way into the address books. */
|
||||
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
||||
.composer-field label .link-btn:hover { color: var(--accent); }
|
||||
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
@@ -13,6 +13,7 @@ import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { isValidEmail } from "@/lib/address";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { FilePicker } from "./FilePicker";
|
||||
import { RecipientPicker, type Field } from "./RecipientPicker";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
@@ -30,6 +31,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
const addFromFiles = useCompose((s) => s.addFromFiles);
|
||||
const filesAvailable = useFiles((s) => s.available);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [addressBookOpen, setAddressBookOpen] = useState(false);
|
||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
@@ -174,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-to`}>To</label>
|
||||
<label htmlFor={`${key}-to`}>
|
||||
{/* Opens the address books. Autocomplete only helps someone who
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
opens it too, for anyone who tries that first. */}
|
||||
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
@@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
||||
</Popover>
|
||||
{addressBookOpen && (
|
||||
<RecipientPicker
|
||||
onPick={(field: Field, addresses) => {
|
||||
// Added to whatever is already there, and the field is opened if
|
||||
// it was hidden -- picking a Bcc should not put one somewhere
|
||||
// the writer cannot see it.
|
||||
const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc;
|
||||
const merged = [...existing];
|
||||
for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a);
|
||||
patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) });
|
||||
}}
|
||||
onClose={() => setAddressBookOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
|
||||
{canSchedule && scheduleOpen && (
|
||||
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Book, BookOpen, Search, Users, X } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
||||
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
||||
|
||||
export type Field = "to" | "cc" | "bcc";
|
||||
|
||||
/** One selectable address: a card can carry several, so the address is the unit. */
|
||||
interface Row {
|
||||
key: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
book: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose recipients by looking through the address books.
|
||||
*
|
||||
* Autocomplete answers "finish this name for me", which is only useful when the
|
||||
* writer already knows who they want. This answers the other question -- who is
|
||||
* there? -- so the books can be read rather than recalled, and several people
|
||||
* picked in one pass rather than typed one at a time.
|
||||
*
|
||||
* Each address is its own row, not each person: someone with a work address and
|
||||
* a personal one is a choice to make, and a picker that offered the card and
|
||||
* quietly took the first address would make it for them.
|
||||
*
|
||||
* Shared books are in here on the same footing as the reader's own, which is
|
||||
* the point of having added them -- with the account named, so it is never a
|
||||
* mystery whose list a name came from.
|
||||
*/
|
||||
export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) {
|
||||
const contacts = useContacts();
|
||||
const [q, setQ] = useState("");
|
||||
const [bookKey, setBookKey] = useState<string>("all");
|
||||
const [picked, setPicked] = useState<Record<string, Row>>({});
|
||||
|
||||
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
|
||||
const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: Row[] = [];
|
||||
const push = (card: ContactCard, book: string, keyPrefix: string) => {
|
||||
for (const a of contactEmails(card)) {
|
||||
if (!a.email) continue;
|
||||
out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book });
|
||||
}
|
||||
};
|
||||
if (bookKey === "all" || !bookKey.includes(":")) {
|
||||
for (const c of Object.values(contacts.cards)) {
|
||||
if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue;
|
||||
push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own");
|
||||
}
|
||||
}
|
||||
if (bookKey === "all" || bookKey.includes(":")) {
|
||||
for (const [key, card] of Object.entries(contacts.sharedCards)) {
|
||||
const accountId = key.slice(0, key.length - card.id.length - 1);
|
||||
const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]);
|
||||
if (!inBook) continue;
|
||||
if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue;
|
||||
push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId);
|
||||
}
|
||||
}
|
||||
const needle = q.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle))
|
||||
: out;
|
||||
return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
}, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]);
|
||||
|
||||
const chosen = Object.values(picked);
|
||||
const toggle = (r: Row) =>
|
||||
setPicked((p) => {
|
||||
const next = { ...p };
|
||||
if (next[r.key]) delete next[r.key];
|
||||
else next[r.key] = r;
|
||||
return next;
|
||||
});
|
||||
|
||||
const send = (field: Field) => {
|
||||
onPick(field, chosen.map((r) => ({ name: r.name, email: r.email })));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Choose recipients"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
|
||||
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
|
||||
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
||||
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="row gap-8" style={{ marginBottom: 10 }}>
|
||||
{/* Same shape as the contact list's own search box. */}
|
||||
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={15} className="faint" />
|
||||
<input
|
||||
className="grow"
|
||||
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
||||
placeholder="Search names and addresses"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
|
||||
<option value="all">All address books</option>
|
||||
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
{subscribed.map((b) => (
|
||||
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
|
||||
{b.book.name} · {b.accountName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chosen.length > 0 && (
|
||||
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||
{chosen.map((r) => (
|
||||
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
|
||||
{r.name ?? r.email} <X size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
||||
{!rows.length ? (
|
||||
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
|
||||
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
|
||||
<span className="grow truncate">
|
||||
{r.name ?? r.email}
|
||||
{r.name && <span className="hint"> · {r.email}</span>}
|
||||
</span>
|
||||
<span className="hint nowrap">{r.book}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!ownBooks.length && !subscribed.length && (
|
||||
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user