diff --git a/web/src/styles/app.css b/web/src/styles/app.css index add8b79..b9c3578 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index bb368a3..1e0b7d4 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -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 }) { )}
- + patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} /> + {/* 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. */} + {!d.showCc && } {!d.showBcc && } {!d.showReplyTo && } @@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) { } label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} + {addressBookOpen && ( + { + // 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 && void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />} {canSchedule && scheduleOpen && ( setScheduleOpen(false)} onPick={scheduleFor} /> diff --git a/web/src/views/compose/RecipientPicker.tsx b/web/src/views/compose/RecipientPicker.tsx new file mode 100644 index 0000000..f0ad31f --- /dev/null +++ b/web/src/views/compose/RecipientPicker.tsx @@ -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("all"); + const [picked, setPicked] = useState>({}); + + 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 ( + + + + + + + } + > +
+ {/* Same shape as the contact list's own search box. */} + + +
+ + {chosen.length > 0 && ( +
+ {chosen.map((r) => ( + + ))} +
+ )} + +
+ {!rows.length ? ( +

{q ? "Nobody matches that." : "No contacts in this address book."}

+ ) : ( + rows.map((r) => ( + + )) + )} +
+ + {!ownBooks.length && !subscribed.length && ( +

No address books yet.

+ )} +
+ ); +}