Add contacts by right-clicking anyone named in a message
Right-clicking a sender, or any address in the message details, opens a menu offering to add that person to the address book - plus edit them when they are already known, write to them, or copy the address. "Add to contacts" opens the contact editor prefilled rather than saving silently, so the address book gets a real card that the user can complete, not a bare email address. contactFromAddress splits the display name into JSContact name components: "Ada Lovelace" into given and surname, "Lovelace, Ada" unpicked, a single word as the given name, and a name that is really just an address left off entirely. Addresses in the details block were joined into one string, so they are now rendered per address to be individually targetable. ContactEditor previously ignored a prefilled name on an unsaved card - it read name components only when the card had an id - so it now reads them either way.
This commit is contained in:
@@ -26,7 +26,8 @@ type AddrRow = { key: string; ctx: string; street: string; city: string; region:
|
||||
export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) {
|
||||
const contacts = useContacts();
|
||||
const isNew = !card.id;
|
||||
const np = card.id ? nameParts(card as ContactCard) : { given: "", surname: "", middle: "", prefix: "", suffix: "" };
|
||||
// Read from the card whether it is saved or seeded (e.g. from a message header).
|
||||
const np = nameParts(card as ContactCard);
|
||||
const [kind, setKind] = useState<"individual" | "group" | "org">((card.kind as "individual" | "group" | "org") ?? "individual");
|
||||
const [given, setGiven] = useState(np.given);
|
||||
const [surname, setSurname] = useState(np.surname);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { Copy, Mail, Pencil, UserPlus } from "lucide-react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { contactFromAddress } from "@/lib/contacts";
|
||||
import { formatAddress } from "@/lib/address";
|
||||
import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "../contacts/ContactEditor";
|
||||
|
||||
/**
|
||||
* Right-click on anyone named in a message — sender, recipients, Reply-To — to
|
||||
* add them to the address book. The contact editor opens prefilled rather than
|
||||
* saving silently, so the address book gets a real card and not just a stray
|
||||
* email address.
|
||||
*/
|
||||
export function useAddressMenu() {
|
||||
const [menu, setMenu] = useState<{ anchor: Anchor; address: EmailAddress } | null>(null);
|
||||
const [editing, setEditing] = useState<ReturnType<typeof contactFromAddress> | null>(null);
|
||||
const contacts = useContacts();
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const open = useCallback((ev: MouseEvent, address: EmailAddress) => {
|
||||
if (!address.email) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setMenu({ anchor: { x: ev.clientX, y: ev.clientY }, address });
|
||||
// The books are needed the moment "Add to contacts" is chosen.
|
||||
const st = useContacts.getState();
|
||||
if (st.available && !st.loaded && !st.loading) void st.loadAll();
|
||||
}, []);
|
||||
|
||||
const close = () => setMenu(null);
|
||||
const known = menu ? contacts.lookupByEmail(menu.address.email) : undefined;
|
||||
const books = Object.values(contacts.books);
|
||||
const defaultBookId = (books.find((b) => b.isDefault) ?? books[0])?.id ?? null;
|
||||
|
||||
const node: ReactNode = (
|
||||
<>
|
||||
{menu && (
|
||||
<Popover anchor={menu.anchor} onClose={close} width={230} ariaLabel={`Actions for ${menu.address.email}`}>
|
||||
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
|
||||
{contacts.available && (
|
||||
known ? (
|
||||
<MenuItem icon={<Pencil size={16} />} label="Edit contact" onClick={() => { setEditing(known); close(); }} />
|
||||
) : (
|
||||
<MenuItem icon={<UserPlus size={16} />} label="Add to contacts" onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
|
||||
)
|
||||
)}
|
||||
<MenuItem icon={<Mail size={16} />} label="New message to this address" onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
icon={<Copy size={16} />}
|
||||
label="Copy email address"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(menu.address.email).then(
|
||||
() => toast.show("Address copied"),
|
||||
() => toast.error("Could not copy the address"),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
{editing && (
|
||||
<ContactEditor
|
||||
card={editing}
|
||||
defaultBookId={defaultBookId}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return { open, node };
|
||||
}
|
||||
|
||||
/** Comma-separated addresses, each of them right-clickable. */
|
||||
export function AddressList({ list, onContext, empty = "—" }: { list: EmailAddress[] | null | undefined; onContext: (ev: MouseEvent, a: EmailAddress) => void; empty?: string }) {
|
||||
if (!list?.length) return <>{empty}</>;
|
||||
return (
|
||||
<>
|
||||
{list.map((a, i) => (
|
||||
<span key={`${a.email}-${i}`}>
|
||||
{i > 0 && ", "}
|
||||
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title="Right-click for options">{formatAddress(a)}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { toast } from "@/ui/toast";
|
||||
import type { ListActions } from "./MessageList";
|
||||
import { InviteCard } from "./InviteCard";
|
||||
import { VCardCard } from "./VCardCard";
|
||||
import { AddressList, useAddressMenu } from "./AddressMenu";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
interface Props {
|
||||
@@ -40,6 +41,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const addrMenu = useAddressMenu();
|
||||
const from = e.from?.[0];
|
||||
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
|
||||
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
|
||||
@@ -128,9 +130,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
||||
<Avatar who={from ?? null} />
|
||||
<div className="who">
|
||||
<div className="from">
|
||||
<span>{displayName(from)}</span>
|
||||
{expanded && from && <span className="email"><{from.email}></span>}
|
||||
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
|
||||
<span className="addr">{displayName(from)}</span>
|
||||
{expanded && from && <span className="email addr"><{from.email}></span>}
|
||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
||||
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
||||
</div>
|
||||
@@ -184,12 +186,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
<>
|
||||
{details && (
|
||||
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
||||
<dt>From</dt><dd>{(e.from ?? []).map(formatAddress).join(", ")}</dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd>{e.sender.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>Reply-To</dt><dd>{e.replyTo.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>To</dt><dd>{(e.to ?? []).map(formatAddress).join(", ") || "—"}</dd>
|
||||
{e.cc?.length ? <><dt>Cc</dt><dd>{e.cc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.bcc?.length ? <><dt>Bcc</dt><dd>{e.bcc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>To</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
|
||||
{e.cc?.length ? <><dt>Cc</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.bcc?.length ? <><dt>Bcc</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
@@ -220,6 +222,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{addrMenu.node}
|
||||
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
||||
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
|
||||
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||
|
||||
Reference in New Issue
Block a user