diff --git a/components/email/__tests__/address-context-menu.test.tsx b/components/email/__tests__/address-context-menu.test.tsx new file mode 100644 index 000000000..4801007c4 --- /dev/null +++ b/components/email/__tests__/address-context-menu.test.tsx @@ -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) => (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; + +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(<> {}} />); + 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(); + 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(); + 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(); + 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(); + 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(<>); + 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(); + rightClickSender(); + expect(screen.queryByTestId('address-menu-search')).toBeNull(); + }); +}); diff --git a/components/email/address-context-menu.tsx b/components/email/address-context-menu.tsx new file mode 100644 index 000000000..fe4ee8fbb --- /dev/null +++ b/components/email/address-context-menu.tsx @@ -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 ( + + void copy())} testId="address-menu-copy" /> + + {contact ? ( + onViewContact && ( + onViewContact(contact, email))} + testId="address-menu-contact" + /> + ) + ) : ( + + )} + {mailSearchAvailable() && ( + <> + + requestMailSearch({ from: email }))} + testId="address-menu-search" + /> + + )} + + ); +} + +/** 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(); + return { + onContextMenu: (e, address) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu(e, address); + }, + menu: ( + + ), + }; +} + +/** 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 ( + <> + onContextMenu(e, { name, email })}>{email} + {menu} + + ); +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dc1ed92b0..fa7938b8e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 : ''} {/* Email address under name (on the name's line under Mountain View) */} {!mvViewer && sender?.email && sender?.name && ( -
{sender.email}
+
)} @@ -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 = { - 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')); }); }} /> )} diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index caaf8bc78..dad7efedb 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -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(null); const triggerRef = useRef(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