From 0ced5482d5624662da4ec8cc19e6d062256e2165 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:03:15 +0100 Subject: [PATCH] feat: list your contacts in the composer's recipient picker, with the directory on its own tab --- .../directory-picker-dialog.test.tsx | 73 +++++++++++- components/contacts/directory-browser.tsx | 43 ++++--- .../contacts/directory-picker-dialog.tsx | 111 ++++++++++++++++-- .../email/__tests__/composer-mention.test.tsx | 17 +++ components/email/email-composer.tsx | 9 +- locales/ar/common.json | 9 +- locales/ca/common.json | 9 +- locales/cs/common.json | 9 +- locales/da/common.json | 9 +- locales/de/common.json | 9 +- locales/en/common.json | 9 +- locales/es/common.json | 9 +- locales/et/common.json | 9 +- locales/fa/common.json | 9 +- locales/fr/common.json | 9 +- locales/he/common.json | 9 +- locales/hu/common.json | 9 +- locales/it/common.json | 9 +- locales/ja/common.json | 9 +- locales/ko/common.json | 9 +- locales/lv/common.json | 9 +- locales/mn/common.json | 9 +- locales/nb/common.json | 9 +- locales/nl/common.json | 9 +- locales/pl/common.json | 9 +- locales/pt/common.json | 9 +- locales/ro/common.json | 9 +- locales/ru/common.json | 9 +- locales/sk/common.json | 9 +- locales/th/common.json | 9 +- locales/tr/common.json | 9 +- locales/uk/common.json | 9 +- locales/vi/common.json | 9 +- locales/zh-TW/common.json | 9 +- locales/zh/common.json | 9 +- 35 files changed, 462 insertions(+), 61 deletions(-) diff --git a/components/contacts/__tests__/directory-picker-dialog.test.tsx b/components/contacts/__tests__/directory-picker-dialog.test.tsx index 192f8dc4b..8f41baf9d 100644 --- a/components/contacts/__tests__/directory-picker-dialog.test.tsx +++ b/components/contacts/__tests__/directory-picker-dialog.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DirectoryPickerDialog } from '../directory-picker-dialog'; import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { ContactCard } from '@/lib/jmap/types'; // next-intl is mocked globally in vitest.setup (t returns the key). @@ -25,7 +26,11 @@ function rows() { } describe('DirectoryPickerDialog', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + // No contacts of the user's own: the picker opens on the directory. + useContactStore.setState({ contacts: [] }); + }); it('lists the directory before any search, one page at a time', () => { setDirectory(people(60), 'ready'); @@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => { expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument(); }); }); + +// compose-17: the picker listed only the server's directory, not the +// contacts the To field's suggestions offer. +describe('DirectoryPickerDialog with contacts', () => { + function contact(id: string, name: string, ...emails: string[]): ContactCard { + return { + id, addressBookIds: { book: true }, + name: { full: name }, + emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])), + } as unknown as ContactCard; + } + + beforeEach(() => { + vi.clearAllMocks(); + setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready'); + useContactStore.setState({ + contacts: [ + contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'), + contact('c2', 'Anna Becker', 'anna@example.com'), + { id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard, + ], + }); + }); + + it('opens on the contacts, one row per address, without groups', () => { + render(); + + expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true'); + expect(rows().map((r) => r.textContent)).toEqual([ + expect.stringContaining('anna@example.com'), + expect.stringContaining('zoe.f@work.example'), + expect.stringContaining('zoe@example.com'), + ]); + expect(screen.queryByText('Server Account')).not.toBeInTheDocument(); + }); + + it('searches the contacts', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } }); + expect(rows()).toHaveLength(1); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } }); + expect(screen.getByText('contacts_no_match')).toBeInTheDocument(); + }); + + it('adds what was checked on both tabs', () => { + const onAdd = vi.fn(); + render(); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' })); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByText('add_to')); + + expect(onAdd).toHaveBeenCalledWith('to', [ + expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }), + expect.objectContaining({ email: 'server@example.org' }), + ]); + }); + + it('shows the contacts alone where the server has no directory', () => { + const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient; + render(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect(rows()).toHaveLength(3); + }); +}); + diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx index 5f5f89d8b..1998b702b 100644 --- a/components/contacts/directory-browser.tsx +++ b/components/contacts/directory-browser.tsx @@ -51,6 +51,13 @@ interface DirectoryBrowserProps { /** Set while the list is being printed. */ printing?: boolean; className?: string; + /** + * Browse these instead of the server's directory (the recipient picker's + * Contacts tab): nothing is fetched and there is nothing to refresh. + */ + entries?: DirectoryEntry[]; + /** With `entries`: the list's name and what an empty list or search says. */ + labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string }; } /** @@ -69,13 +76,17 @@ export function DirectoryBrowser({ onPrint, printing = false, className, + entries: ownEntries, + labels, }: DirectoryBrowserProps) { const t = useTranslations("contacts.directory"); const tContacts = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); - const entries = useContactStore((s) => s.directoryPrincipals); - const status = useContactStore((s) => s.directoryStatus); + const directoryEntries = useContactStore((s) => s.directoryPrincipals); + const directoryStatus = useContactStore((s) => s.directoryStatus); + const entries = ownEntries ?? directoryEntries; + const status = ownEntries ? "ready" : directoryStatus; const fetchDirectory = useContactStore((s) => s.fetchDirectory); const [query, setQuery] = useState(""); const [page, setPage] = useState(0); @@ -83,8 +94,8 @@ export function DirectoryBrowser({ // The directory loads at sign-in; fetch here only if that never ran. useEffect(() => { - if (client && status === "idle") void fetchDirectory(client); - }, [client, status, fetchDirectory]); + if (!ownEntries && client && status === "idle") void fetchDirectory(client); + }, [ownEntries, client, status, fetchDirectory]); useEffect(() => { if (autoFocusSearch) searchRef.current?.focus(); @@ -117,7 +128,9 @@ export function DirectoryBrowser({ }; let placeholder: React.ReactNode = null; - if (entries.length === 0) { + if (entries.length === 0 && ownEntries) { + placeholder = } title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />; + } else if (entries.length === 0) { if (loading) { placeholder = ( } title={t("loading")} /> @@ -132,7 +145,7 @@ export function DirectoryBrowser({ placeholder = } title={t("empty_title")} hint={t("empty_hint")} />; } } else if (filtered.length === 0) { - placeholder = } title={t("no_match")} />; + placeholder = } title={labels?.noMatch ?? t("no_match")} />; } return ( @@ -163,13 +176,15 @@ export function DirectoryBrowser({ onClear={() => { setQuery(""); setPage(0); }} clearLabel={tContacts("clear_search")} /> - - - + {!ownEntries && ( + + + + )} {onPrint && ( @@ -186,7 +201,7 @@ export function DirectoryBrowser({ )} -
+
{placeholder ?? pageEntries.map((entry) => { const key = directoryKey(entry); const checked = selected.has(key); diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx index 493a84e3c..ca031d91c 100644 --- a/components/contacts/directory-picker-dialog.tsx +++ b/components/contacts/directory-picker-dialog.tsx @@ -1,36 +1,94 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { X } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { cn } from "@/lib/utils"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import { useContactStore, type DirectoryEntry } from "@/stores/contact-store"; +import type { ContactCard } from "@/lib/jmap/types"; +import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store"; import { DirectoryBrowser, directoryKey } from "./directory-browser"; export type RecipientField = "to" | "cc" | "bcc"; +type PickerTab = "contacts" | "directory"; + interface DirectoryPickerDialogProps { client: IJMAPClient | null; onClose: () => void; - /** Called with the checked entries in directory order; the dialog closes afterwards. */ + /** Called with the checked entries in list order; the dialog closes afterwards. */ onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void; } -/** The composer's "Select from directory" dialog. */ +/** Every address of the user's own contacts, one row each; groups are left out. */ +export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] { + const seen = new Set(); + const entries: DirectoryEntry[] = []; + for (const contact of contacts) { + if (contact.kind === "group") continue; + const name = getContactDisplayName(contact); + for (const email of Object.values(contact.emails ?? {})) { + const address = email?.address?.trim(); + if (!address) continue; + const entry = { id: `${contact.id}:${address}`, name: name || address, email: address }; + if (seen.has(directoryKey(entry))) continue; + seen.add(directoryKey(entry)); + entries.push(entry); + } + } + return entries; +} + +/** + * The composer's recipient picker: the user's own contacts, and the server's + * directory (other accounts) on a tab of its own where the server has one. + * It used to list only the directory, without the contacts autocomplete offers. + */ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) { const t = useTranslations("contacts.directory"); + const tSend = useTranslations("compose_send"); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true }); + const contacts = useContactStore((s) => s.contacts); + const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]); + const hasDirectory = !!client?.supportsPrincipals?.(); + // Contacts first; straight to the directory for someone who has none. + const [tab, setTab] = useState(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts")); const [selected, setSelected] = useState>(() => new Set()); const add = (field: RecipientField) => { - const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e))); + // Checked on either tab, each address once. + const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals]; + const seen = new Set(); + const entries = pool.filter((e) => { + const key = directoryKey(e); + if (!selected.has(key) || seen.has(key)) return false; + seen.add(key); + return true; + }); if (entries.length === 0) return; onAdd(field, entries); onClose(); }; + const tabButton = (value: PickerTab, label: string) => ( + + ); + return (

- {t("picker_title")} + {tSend("picker_title")}

- + {hasDirectory && ( +
+ {tabButton("contacts", tSend("tab_contacts"))} + {tabButton("directory", tSend("tab_directory"))} +
+ )} + +
+ +
diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index d50d46ad5..42c312b12 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Editor } from '@tiptap/core'; import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; @@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => { expect(to.value).toBe('Sarah Kim '); }); + // compose-17: the button was there only with a server directory, so + // contacts alone were never offered. + it('offers the recipient picker for the contacts when the server has no directory', async () => { + const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState(); + contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }]; + try { + await renderComposer(); + const button = screen.getByTestId('composer-directory-picker'); + expect(button).toHaveAccessibleName('picker_button'); + fireEvent.click(button); + expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument(); + } finally { + contactState.contacts = []; + } + }); + // The directory picker is drawn inside the composer itself, not portaled. it('lets Escape in the directory picker leave the composer alone', async () => { // Every other capability answers nothing. diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 22ea0db2b..5f4a9ab85 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1775,6 +1775,9 @@ export function EmailComposer({ // Whether a Sent mailbox is known so the on-demand server search is worth // offering (falls back to hiding the "search the server" row otherwise). const canSearchServer = useContactStore((s) => s.sentMailboxId != null); + // The recipient picker: the user's contacts, and the server's directory. + const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0); + const tSend = useTranslations('compose_send'); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); @@ -5091,15 +5094,15 @@ export function EmailComposer({ onMoveChip={handleMoveChip} />
- {activeClient?.supportsPrincipals?.() && ( + {(hasContacts || activeClient?.supportsPrincipals?.()) && (