Merge pull request #11 from LINUXexpert-org/contact-from-message
Add contacts by right-clicking anyone named in a message
This commit is contained in:
@@ -32,6 +32,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
|
||||
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
|
||||
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
|
||||
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
|
||||
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
|
||||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contactFromAddress, nameParts } from "../contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const parts = (name: string | null, email = "[email protected]") =>
|
||||
nameParts(contactFromAddress({ name, email }) as ContactCard);
|
||||
|
||||
describe("contactFromAddress", () => {
|
||||
it("keeps the address as the preferred email", () => {
|
||||
const card = contactFromAddress({ name: "Ada Lovelace", email: "[email protected]" });
|
||||
const emails = Object.values(card.emails ?? {});
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]).toMatchObject({ address: "[email protected]", pref: 1 });
|
||||
expect(card.kind).toBe("individual");
|
||||
});
|
||||
|
||||
it("splits a display name into components", () => {
|
||||
expect(parts("Ada Lovelace")).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
expect(parts("Ada King Lovelace")).toMatchObject({ given: "Ada", middle: "King", surname: "Lovelace" });
|
||||
expect(parts("Prince")).toMatchObject({ given: "Prince", surname: "" });
|
||||
});
|
||||
|
||||
it("unpicks the surname-first form", () => {
|
||||
expect(parts("Lovelace, Ada")).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
});
|
||||
|
||||
it("strips surrounding quotes", () => {
|
||||
expect(parts('"Ada Lovelace"')).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
});
|
||||
|
||||
it("leaves the name empty when the header carries an address, not a name", () => {
|
||||
expect(contactFromAddress({ name: "[email protected]", email: "[email protected]" }).name).toBeUndefined();
|
||||
expect(contactFromAddress({ name: null, email: "[email protected]" }).name).toBeUndefined();
|
||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -147,3 +147,34 @@ function fold(line: string): string {
|
||||
export function newKey(prefix = "k"): string {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new contact card seeded from an email address.
|
||||
*
|
||||
* The display name in a From header is one string, so it has to be split into
|
||||
* name components: "Ada Lovelace" gives given + surname, the "Lovelace, Ada"
|
||||
* form is unpicked, and a single word becomes the given name. Anything that
|
||||
* looks like an address rather than a name is left out — a card named
|
||||
* "[email protected]" helps nobody.
|
||||
*/
|
||||
export function contactFromAddress(addr: EmailAddress): Partial<ContactCard> {
|
||||
const card: Partial<ContactCard> = {
|
||||
kind: "individual",
|
||||
emails: { [newKey("e")]: { "@type": "EmailAddress", address: addr.email, pref: 1 } },
|
||||
};
|
||||
const raw = (addr.name ?? "").trim().replace(/^["']|["']$/g, "").trim();
|
||||
if (!raw || raw.includes("@")) return card;
|
||||
const [surnameFirst, givenRest] = raw.includes(",") ? raw.split(",", 2) : [];
|
||||
const parts = surnameFirst && givenRest
|
||||
? { given: givenRest.trim(), surname: surnameFirst.trim() }
|
||||
: splitName(raw);
|
||||
const name = buildName(parts);
|
||||
if (name) card.name = name;
|
||||
return card;
|
||||
}
|
||||
|
||||
function splitName(full: string): { given: string; middle: string; surname: string } {
|
||||
const words = full.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 1) return { given: words[0]!, middle: "", surname: "" };
|
||||
return { given: words[0]!, middle: words.slice(1, -1).join(" "), surname: words[words.length - 1]! };
|
||||
}
|
||||
|
||||
@@ -851,6 +851,10 @@ img { max-width: 100%; }
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
/* Addresses in a message carry a right-click menu (see mail/AddressMenu.tsx). */
|
||||
.addr { cursor: context-menu; }
|
||||
.message-details .addr:hover, .message-head .from .addr:hover { text-decoration: underline dotted; text-underline-offset: 2px; }
|
||||
|
||||
/* ---- Date & time fields (custom pickers; see ui/datefield.tsx) ---- */
|
||||
.dp-field { position: relative; display: inline-flex; align-items: center; width: 100%; }
|
||||
.dp-field .input { width: 100%; padding-right: 30px; }
|
||||
|
||||
@@ -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