feat: add copy, write, add to contacts and find mail to the right-click on an address in a message

This commit is contained in:
Linus Rath
2026-11-04 06:21:03 +01:00
parent 5be6154065
commit acb827bf18
7 changed files with 341 additions and 20 deletions
@@ -0,0 +1,105 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useContactStore } from '@/stores/contact-store';
import { useToastStore } from '@/stores/toast-store';
import { useMailSearchRequests, type MailSearchRequest } from '@/lib/mail-search-request';
// mail-25: a right-click on the sender in a message header did nothing (the
// desktop app showed no menu at all).
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) => (values ? `${key} ${JSON.stringify(values)}` : key),
}));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => (email: string) => email }));
const requestInternalMailto = vi.hoisted(() => vi.fn((_href: string) => true));
vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto }));
const added = vi.hoisted(() => vi.fn(async (_email: string, _name?: string) => true));
vi.mock('@/lib/contacts/add-from-address', () => ({ addAddressToContacts: added }));
const { RecipientPopover } = await import('../recipient-popover');
const original = useContactStore.getState();
let writeText: ReturnType<typeof vi.fn>;
function MailPage({ onSearch }: { onSearch: (request: MailSearchRequest) => void }) {
useMailSearchRequests(onSearch, true);
return null;
}
function rightClickSender() {
fireEvent.contextMenu(screen.getByRole('button', { name: 'Sam Lee' }), { clientX: 40, clientY: 30 });
}
beforeEach(() => {
writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
useContactStore.setState({ contacts: [] });
useToastStore.getState().clearToasts();
requestInternalMailto.mockClear();
added.mockClear();
});
afterEach(() => {
cleanup();
useContactStore.setState(original);
});
describe('right-click on an address in a message header', () => {
it('offers Copy address, New message, Add to contacts and Find emails from', () => {
render(<><RecipientPopover name="Sam Lee" email="sam@example.org" /><MailPage onSearch={() => {}} /></>);
rightClickSender();
const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
expect(items).toEqual([
'desktopUi.context.copy_address',
'email_composer.new_message',
'email_viewer.contact_sidebar.add_to_contacts',
'context_menu.find_emails_from {"name":"Sam Lee"}',
]);
});
it('copies the address', async () => {
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
rightClickSender();
fireEvent.click(screen.getByTestId('address-menu-copy'));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('sam@example.org'));
});
it('writes to it in the app\'s composer', () => {
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
rightClickSender();
fireEvent.click(screen.getByTestId('address-menu-write'));
expect(requestInternalMailto).toHaveBeenCalledWith('mailto:sam@example.org');
});
it('adds it to the contacts, with the name from the header', async () => {
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
rightClickSender();
fireEvent.click(screen.getByTestId('address-menu-contact'));
await waitFor(() => expect(added).toHaveBeenCalledWith('sam@example.org', 'Sam Lee'));
});
it('a saved contact opens instead of being added again', () => {
useContactStore.setState({ contacts: [{ id: 'c1', emails: { e: { address: 'SAM@example.org' } } }] as never });
const onViewContact = vi.fn();
render(<RecipientPopover name="Sam Lee" email="sam@example.org" onViewContact={onViewContact} />);
rightClickSender();
expect(screen.getByTestId('address-menu-contact').textContent).toBe('email_viewer.contact_sidebar.view_contact');
fireEvent.click(screen.getByTestId('address-menu-contact'));
expect(onViewContact).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' }), 'sam@example.org');
expect(added).not.toHaveBeenCalled();
});
it('finds the mail from this sender on the mail page', () => {
const onSearch = vi.fn();
render(<><RecipientPopover name="Sam Lee" email="sam@example.org" /><MailPage onSearch={onSearch} /></>);
rightClickSender();
act(() => fireEvent.click(screen.getByTestId('address-menu-search')));
expect(onSearch).toHaveBeenCalledWith({ from: 'sam@example.org' });
});
it('leaves out the search where there is no mail page (a message window)', () => {
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
rightClickSender();
expect(screen.queryByTestId('address-menu-search')).toBeNull();
});
});
+132
View File
@@ -0,0 +1,132 @@
"use client";
// The right-click menu on an address in a message header: copy it, write to
// it, keep it as a contact, find the mail it sent. In the desktop app the
// menu shows natively (components/ui/context-menu).
import type { MouseEvent, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, type ContextMenuPosition } from "@/components/ui/context-menu";
import { Copy, ExternalLink, Search, Send, UserPlus } from "@/components/icons";
import { useContextMenu } from "@/hooks/use-context-menu";
import { addAddressToContacts } from "@/lib/contacts/add-from-address";
import { mailSearchAvailable, requestMailSearch } from "@/lib/mail-search-request";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import type { ContactCard } from "@/lib/jmap/types";
import { useContactStore } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
export interface MenuAddress {
name?: string;
email: string;
}
interface AddressContextMenuProps {
address: MenuAddress | null;
isOpen: boolean;
position: ContextMenuPosition;
onClose: () => void;
/** Shows a saved contact; without it a saved address offers nothing more. */
onViewContact?: (contact: ContactCard | null, email: string) => void;
}
function contactFor(contacts: ContactCard[], email: string): ContactCard | undefined {
const wanted = email.toLowerCase();
return contacts.find((c) => c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase() === wanted));
}
export function AddressContextMenu({ address, isOpen, position, onClose, onViewContact }: AddressContextMenuProps) {
const t = useTranslations();
const contacts = useContactStore((s) => s.contacts);
if (!address) return null;
const { email, name } = address;
const contact = contactFor(contacts, email);
const run = (action: () => void) => () => {
onClose();
action();
};
const copy = async () => {
try {
await navigator.clipboard.writeText(email);
toast.success(t("email_viewer.contact_sidebar.copied"));
} catch {
toast.error(t("email_viewer.contact_sidebar.copy_failed"));
}
};
const write = () => {
const href = `mailto:${email}`;
// No mail page here to take it (a message window): the system's handler.
if (!requestInternalMailto(href)) window.location.href = href;
};
const save = () => {
void addAddressToContacts(email, name).then((created) => {
if (created) toast.success(t("email_viewer.contact_added"));
});
};
return (
<ContextMenu isOpen={isOpen} position={position} onClose={onClose} label={name || email}>
<ContextMenuItem icon={Copy} label={t("desktopUi.context.copy_address")} onClick={run(() => void copy())} testId="address-menu-copy" />
<ContextMenuItem icon={Send} label={t("email_composer.new_message")} onClick={run(write)} testId="address-menu-write" />
{contact ? (
onViewContact && (
<ContextMenuItem
icon={ExternalLink}
label={t("email_viewer.contact_sidebar.view_contact")}
onClick={run(() => onViewContact(contact, email))}
testId="address-menu-contact"
/>
)
) : (
<ContextMenuItem icon={UserPlus} label={t("email_viewer.contact_sidebar.add_to_contacts")} onClick={run(save)} testId="address-menu-contact" />
)}
{mailSearchAvailable() && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Search}
label={t("context_menu.find_emails_from", { name: name || email })}
onClick={run(() => requestMailSearch({ from: email }))}
testId="address-menu-search"
/>
</>
)}
</ContextMenu>
);
}
/** A right-click menu for one address, for the element that shows it. */
export function useAddressContextMenu(onViewContact?: (contact: ContactCard | null, email: string) => void): {
onContextMenu: (e: MouseEvent, address: MenuAddress) => void;
menu: ReactNode;
} {
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<MenuAddress>();
return {
onContextMenu: (e, address) => {
e.preventDefault();
e.stopPropagation();
openContextMenu(e, address);
},
menu: (
<AddressContextMenu
address={contextMenu.data}
isOpen={contextMenu.isOpen}
position={contextMenu.position}
onClose={closeContextMenu}
onViewContact={onViewContact}
/>
),
};
}
/** An address as text, with the address menu on a right-click. */
export function AddressText({ name, email, onViewContact }: MenuAddress & { onViewContact?: (contact: ContactCard | null, email: string) => void }) {
const { onContextMenu, menu } = useAddressContextMenu(onViewContact);
return (
<>
<span onContextMenu={(e) => onContextMenu(e, { name, email })}>{email}</span>
{menu}
</>
);
}
+5 -20
View File
@@ -24,6 +24,7 @@ import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { measureEmailFrameHeight } from "@/lib/email-frame-height";
import { forwardFrameKeys } from "@/lib/email-frame-keys";
import { addAddressToContacts } from "@/lib/contacts/add-from-address";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
import { fitEmailBodyWidth, type FitOptions } from "@/lib/email-fit-width";
import { hasFullBleedCanvas } from "@/lib/email-full-bleed";
@@ -48,7 +49,7 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -156,6 +157,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { AddressText } from "./address-context-menu";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -5143,7 +5145,7 @@ ${printWatermark ? printWatermark.html : ''}
</div>
{/* Email address under name (on the name's line under Mountain View) */}
{!mvViewer && sender?.email && sender?.name && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
<div className="text-sm text-muted-foreground mt-0.5 truncate"><AddressText name={sender.name} email={sender.email} onViewContact={handleViewContactSidebar} /></div>
)}
</div>
</div>
@@ -6305,24 +6307,7 @@ ${printWatermark ? printWatermark.html : ''}
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
const contactData: Partial<ContactCard> = {
emails: { email: { address: addr } },
// `full` feeds the mandatory vCard FN — strict CardDAV clients
// (Apple Contacts) drop cards without it (#430).
...(name ? { name: { components: name.includes(' ')
? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
: [{ kind: 'given' as const, value: name }],
isOrdered: true, full: name,
}} : {}),
};
if (client && supportsSync) {
createContact(client, contactData).then((created) => { if (created) toast.success(t('contact_added')); });
} else {
void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
.then((created) => { if (created) toast.success(t('contact_added')); });
}
void addAddressToContacts(addr, name).then((created) => { if (created) toast.success(t('contact_added')); });
}}
/>
)}
+7
View File
@@ -11,6 +11,7 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import type { ContactCard } from "@/lib/jmap/types";
import { useAddressContextMenu } from "./address-context-menu";
const CARD_WIDTH = 300;
@@ -260,6 +261,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
const addressMenu = useAddressContextMenu(onViewContact);
const handleToggle = () => {
if (anchor) {
@@ -275,6 +277,10 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
<button
ref={triggerRef}
onClick={handleToggle}
onContextMenu={(e) => {
setAnchor(null);
addressMenu.onContextMenu(e, { name, email });
}}
className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words text-start",
className
@@ -293,6 +299,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
ignoreRef={triggerRef}
/>
)}
{addressMenu.menu}
</>
);
}
+7
View File
@@ -110,6 +110,7 @@ import { AppMain } from "@/components/layout/app-landmarks";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useAppCommands } from "@/lib/app-commands";
import { useMailSearchRequests } from "@/lib/mail-search-request";
import { MvSearchForm } from "@/components/search/mv-search-form";
import { MvSidePanel } from "@/components/layout/mv-side-panel";
import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
@@ -1328,6 +1329,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
else backToList();
},
}, isAuthenticated && !!client);
// "Find emails from …" on an address in a message, and the desktop app's
// "Search mail for …" on selected text.
useMailSearchRequests((request) => {
if ("from" in request) void handleSelectContactSuggestion({ name: "", email: request.from }, "from");
else void handleSearch(request.text);
}, isAuthenticated && !!client);
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
// from what's on screen (lib/desktop/commands.ts).
+27
View File
@@ -0,0 +1,27 @@
import type { ContactCard } from '@/lib/jmap/types';
import { generateUUID } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
/**
* Saves an address from a message as a new contact: on the server when the
* account syncs contacts, in this browser otherwise. True when it was saved.
*/
export async function addAddressToContacts(address: string, name?: string): Promise<boolean> {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
const contactData: Partial<ContactCard> = {
emails: { email: { address } },
// `full` feeds the mandatory vCard FN — strict CardDAV clients
// (Apple Contacts) drop cards without it (#430).
...(name ? { name: { components: name.includes(' ')
? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
: [{ kind: 'given' as const, value: name }],
isOrdered: true, full: name,
}} : {}),
};
const created = client && supportsSync
? await createContact(client, contactData)
: await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
return !!created;
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
// A mail search asked for from outside the mail page's search box: "Find
// emails from …" on an address in a message, "Search mail for …" on selected
// text in the desktop app. Through a window event, like lib/app-commands,
// since the search state lives deep inside the mail page. A request nobody
// takes waits for the next mail page that mounts.
import { useEffect, useRef } from 'react';
export type MailSearchRequest = { from: string } | { text: string };
const EVENT = 'bulwark:mail-search';
let pending: MailSearchRequest | null = null;
let listening = 0;
/** Whether a mail page in this window takes searches now. */
export function mailSearchAvailable(): boolean {
return listening > 0;
}
/** Runs a search on the mail page. False when no mail page took it. */
export function requestMailSearch(request: MailSearchRequest): boolean {
if (typeof window === 'undefined') return false;
return !window.dispatchEvent(new CustomEvent<MailSearchRequest>(EVENT, { detail: request, cancelable: true }));
}
/** Keeps a search for the next mail page, for a caller that is about to go there. */
export function parkMailSearch(request: MailSearchRequest): void {
pending = request;
}
/** The mail page's side: runs the searches asked for while `enabled`. */
export function useMailSearchRequests(run: (request: MailSearchRequest) => void, enabled: boolean): void {
const ref = useRef(run);
useEffect(() => {
ref.current = run;
});
useEffect(() => {
if (!enabled) return;
const onRequest = (event: Event) => {
event.preventDefault();
ref.current((event as CustomEvent<MailSearchRequest>).detail);
};
window.addEventListener(EVENT, onRequest);
listening += 1;
if (pending) {
const request = pending;
pending = null;
ref.current(request);
}
return () => {
window.removeEventListener(EVENT, onRequest);
listening -= 1;
};
}, [enabled]);
}