mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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')); });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user