From 6bd55c9501756ce1de79feb7a4b12f045a3effef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:05:19 +0200 Subject: [PATCH] fix: export contact photos in batches on Stalwart 1.0 On 1.0 a vCard export sent every blob photo through the avatar download queue, a burst of 40 and then 6 a second, two at a time: about 160 s for 1000 photos. Avatars and print (which gives up after 5 s) waited behind it, the Export button showed nothing, and a second click started the whole export again. - Export fetches the blob media of all cards with Blob/get (`data:asBase64`), asking for the sizes first and packing the requests to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only when the session advertises it; without it, or when a request is refused, the rest is downloaded one by one behind avatars and print, which keep half of the download burst for themselves. - The export buttons in the settings, the bulk bar, the context menu and the detail menu show that an export is running and ignore clicks until it is done. - When the photo changes, the form rebuilds `media` on the card as the server has it now. A logo or sound blobId from the list's older copy made 1.0 refuse the whole update ("The following blobIds do not exist or are not accessible"). - A new photo takes the key, place, `pref`, `label` and `contexts` of the one it replaces, so it stays the photo shown on cards with several. Removing the photo removes every photo the card had instead of only the shown one, which brought up the next; logos and sounds stay. - A card whose login has no client downloads nothing, instead of asking the active login's server and remembering the refusal for two minutes. - Photo downloads run at half the server's `maxConcurrentRequests` instead of a fixed two. - `invalidProperties` naming `media/...` also counts as a refused blobId in media, not only `media` itself. --- .../__tests__/contact-detail.test.tsx | 28 ++- .../contacts/__tests__/contact-export.test.ts | 55 +++++- .../contacts/__tests__/contact-form.test.tsx | 160 ++++++++++++++++++ .../contacts/__tests__/contact-list.test.tsx | 39 ++++- components/contacts/contact-context-menu.tsx | 10 +- components/contacts/contact-detail.tsx | 11 +- components/contacts/contact-export.tsx | 61 ++++--- components/contacts/contact-form.tsx | 84 ++++++--- components/contacts/contact-list.tsx | 12 +- components/contacts/contacts-app.tsx | 5 +- components/settings/contacts-settings.tsx | 17 +- components/ui/__tests__/avatar.test.tsx | 57 ++++++- hooks/__tests__/use-contact-photo.test.ts | 33 ++++ hooks/use-contact-photo.ts | 25 ++- lib/__tests__/contact-media.test.ts | 140 ++++++++++++++- lib/__tests__/contact-photo-cache.test.ts | 113 ++++++++++++- lib/__tests__/jmap-blob-get.test.ts | 160 ++++++++++++++++++ lib/__tests__/jmap-request-limits.test.ts | 26 ++- lib/contact-media.ts | 123 ++++++++++++-- lib/contact-photo-cache.ts | 139 +++++++++++---- lib/jmap/client-interface.ts | 15 ++ lib/jmap/client.ts | 79 ++++++++- lib/jmap/request-limits.ts | 24 +++ locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- stores/__tests__/contact-store.test.ts | 25 +++ stores/contact-store.ts | 9 + 52 files changed, 1375 insertions(+), 156 deletions(-) create mode 100644 hooks/__tests__/use-contact-photo.test.ts create mode 100644 lib/__tests__/jmap-blob-get.test.ts diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx index 2176cde88..fd2fb465f 100644 --- a/components/contacts/__tests__/contact-detail.test.tsx +++ b/components/contacts/__tests__/contact-detail.test.tsx @@ -1,7 +1,9 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ContactDetail } from '../contact-detail'; +import { exportContacts } from '../contact-export'; import type { ContactCard } from '@/lib/jmap/types'; +import type { BlobBatch } from '@/lib/jmap/client-interface'; import { useAuthStore } from '@/stores/auth-store'; import { useContactStore } from '@/stores/contact-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; @@ -106,6 +108,30 @@ describe('ContactDetail', () => { expect(fetchBlob).toHaveBeenCalledWith('dbcX', 'photo', 'image/jpeg', 'a'); }); + it('greys out "Export as vCard" while an export runs', async () => { + let deliver!: (batch: BlobBatch) => void; + useAuthStore.setState({ + client: { + ...(useAuthStore.getState().client as object), + getContactsAccountId: () => 'a', + getBlobsAsBase64: () => new Promise((resolve) => { deliver = resolve; }), + } as never, + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + render(); + let running!: Promise; + act(() => { running = exportContacts([{ ...contact, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }]); }); + + fireEvent.click(screen.getByLabelText('detail.more_actions')); + expect(screen.getByRole('menuitem', { name: /context_menu\.export_vcard/ })).toBeDisabled(); + + await act(async () => { + deliver({ data: new Map([['dbcX', '/9j/']]), failed: [] }); + await running; + }); + expect(screen.getByRole('menuitem', { name: /context_menu\.export_vcard/ })).not.toBeDisabled(); + }); + it('does not show another card\'s photo for the same address', () => { useContactStore.setState({ contacts: [{ ...contact, id: '2', media: { photo: { kind: 'photo', blobId: 'dbcOther' } } }], diff --git a/components/contacts/__tests__/contact-export.test.ts b/components/contacts/__tests__/contact-export.test.ts index 00904224c..1106e5bcc 100644 --- a/components/contacts/__tests__/contact-export.test.ts +++ b/components/contacts/__tests__/contact-export.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { exportContact, exportContacts } from '../contact-export'; +import { renderHook, act } from '@testing-library/react'; +import { exportContact, exportContacts, useContactExportBusy } from '../contact-export'; import { useAuthStore } from '@/stores/auth-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; +import type { BlobBatch } from '@/lib/jmap/client-interface'; import type { ContactCard } from '@/lib/jmap/types'; const card = (id: string, media: ContactCard['media']): ContactCard => ({ @@ -14,6 +16,7 @@ const card = (id: string, media: ContactCard['media']): ContactCard => ({ describe('vCard export of contact photos', () => { const fetchBlob = vi.fn(); + const getContactsAccountId = () => 'a'; let exported: Blob | undefined; beforeEach(() => { @@ -26,7 +29,7 @@ describe('vCard export of contact photos', () => { }); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); - useAuthStore.setState({ client: { fetchBlob } as never }); + useAuthStore.setState({ client: { fetchBlob, getContactsAccountId } as never }); }); afterEach(() => { @@ -36,7 +39,26 @@ describe('vCard export of contact photos', () => { vi.restoreAllMocks(); }); - it('downloads Stalwart 1.0 blob photos and writes them inline', async () => { + it('fetches the Stalwart 1.0 photos of all cards in one Blob/get batch and writes them inline', async () => { + const getBlobsAsBase64 = vi.fn(async (): Promise => ({ + data: new Map([['dbcX', '/9j/'], ['dbcLogo', 'iVBO']]), + failed: [], + })); + useAuthStore.setState({ client: { fetchBlob, getContactsAccountId, getBlobsAsBase64 } as never }); + + expect(await exportContacts([ + card('1', { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } }), + card('2', { logo: { kind: 'logo', blobId: 'dbcLogo', mediaType: 'image/png' } }), + ])).toBe(true); + + const vcf = await exported!.text(); + expect(getBlobsAsBase64).toHaveBeenCalledWith(['dbcX', 'dbcLogo'], 'a'); + expect(fetchBlob).not.toHaveBeenCalled(); + expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:/9j/'); + expect(vcf).toContain('LOGO;ENCODING=b;TYPE=image/png:iVBO'); + }); + + it('downloads Stalwart 1.0 blob photos one by one when the client cannot batch them', async () => { fetchBlob.mockImplementation(async (_blobId: string, _name?: string, type?: string) => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type })); @@ -70,10 +92,35 @@ describe('vCard export of contact photos', () => { const done = exportContact(card('1', { photo: { kind: 'photo', blobId: 'dbcGone', mediaType: 'image/jpeg' } })); await vi.advanceTimersByTimeAsync(1000); - await done; + expect(await done).toBe(true); const vcf = await exported!.text(); expect(vcf).toContain('FN:Contact 1'); expect(vcf).not.toContain('PHOTO'); }); + + it('says it is busy and ignores another export until the first is done', async () => { + let deliver!: (batch: BlobBatch) => void; + const getBlobsAsBase64 = vi.fn(() => new Promise((resolve) => { deliver = resolve; })); + useAuthStore.setState({ client: { fetchBlob, getContactsAccountId, getBlobsAsBase64 } as never }); + const { result } = renderHook(() => useContactExportBusy()); + expect(result.current).toBe(false); + + let first!: Promise; + act(() => { first = exportContacts([card('1', { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } })]); }); + expect(result.current).toBe(true); + + // A second click, from any export button, while the photos download. + expect(await exportContact(card('2', undefined))).toBe(false); + expect(await exportContacts([card('3', undefined)])).toBe(false); + + await act(async () => { + deliver({ data: new Map([['dbcX', '/9j/']]), failed: [] }); + expect(await first).toBe(true); + }); + expect(result.current).toBe(false); + expect(getBlobsAsBase64).toHaveBeenCalledTimes(1); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(await exported!.text()).toContain('FN:Contact 1'); + }); }); diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 73987c95a..3ad1693e4 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ContactForm } from '../contact-form'; import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; +import { findContactPhotoEntry } from '@/stores/contact-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; const existingContact: ContactCard = { @@ -223,6 +224,165 @@ describe('ContactForm', () => { }); expect(onSave.mock.calls[0][1]).toEqual({ photoKey: 'photo1' }); }); + + /** processImageFile draws the picked file onto a canvas; this stands in for it. */ + function stubImagePipeline() { + vi.stubGlobal('Image', class { + width = 10; + height = 10; + onload: (() => void) | null = null; + set src(_value: string) { setTimeout(() => this.onload?.(), 0); } + }); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as never); + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/jpeg;base64,/9j/NEW'); + } + + async function pickPhoto(container: HTMLElement) { + fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, { + target: { files: [new File(['jpeg'], 'me.jpg', { type: 'image/jpeg' })] }, + }); + await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/jpeg;base64,/9j/NEW')); + } + + describe('on a card with several photos', () => { + // A card from Apple Contacts or another CardDAV client can carry more + // than one PHOTO; the one with the lowest pref is the one shown. + const cardPhotos = { + '1.0': { + main: { kind: 'photo', blobId: 'dbcMain', mediaType: 'image/jpeg', pref: 1, label: 'Main', contexts: { work: true } }, + other: { kind: 'photo', blobId: 'dbcOther', mediaType: 'image/jpeg', pref: 5 }, + }, + '0.16': { + main: { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg', pref: 1, label: 'Main', contexts: { work: true } }, + other: { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/Og==', mediaType: 'image/jpeg', pref: 5 }, + }, + } satisfies Record>; + + it.each(['1.0', '0.16'] as const)('gives a new photo the pref, label and contexts of the one it replaces on Stalwart %s', async (version) => { + stubImagePipeline(); + const { main, other } = cardPhotos[version]; + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + + await pickPhoto(container); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + const media = onSave.mock.calls[0][0].media as Record; + expect(media).toEqual({ + main: { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/NEW', mediaType: 'image/jpeg', pref: 1, label: 'Main', contexts: { work: true } }, + other, + logo: logo1_0, + }); + // In the replaced photo's place, and still the one shown. + expect(Object.keys(media)).toEqual(['main', 'other', 'logo']); + expect(findContactPhotoEntry({ media })?.[0]).toBe('main'); + expect(onSave.mock.calls[0][1]).toEqual({ photoKey: 'main' }); + }); + + it.each(['1.0', '0.16'] as const)('removes every photo, not only the one shown, on Stalwart %s', async (version) => { + // Removing only the shown photo would bring up the next one after the + // save, while the form showed none. + const { main, other } = cardPhotos[version]; + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('remove_photo')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + expect(onSave.mock.calls[0][0].media).toEqual({ logo: logo1_0 }); + }); + }); + + describe('with the card as the server has it now', () => { + // The list's copy of the card is older than the server's: the logo was + // replaced elsewhere, so its old blobId no longer resolves on 1.0. + const staleLogo: ContactMedia = { kind: 'logo', blobId: 'dbcLogoOld', mediaType: 'image/png' }; + const currentLogo: ContactMedia = { kind: 'logo', blobId: 'dbcLogoNew', mediaType: 'image/png' }; + + async function saveNewPhoto(loadCurrentMedia: () => Promise | null>) { + stubImagePipeline(); + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + await pickPhoto(container); + fireEvent.submit(screen.getByText('save').closest('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + return onSave.mock.calls[0][0].media as Record; + } + + it('sends the logo the server has now with a new photo on Stalwart 1.0', async () => { + const loadCurrentMedia = vi.fn(async () => ({ media: { photo: photo1_0, logo: currentLogo } })); + + const media = await saveNewPhoto(loadCurrentMedia); + + expect(loadCurrentMedia).toHaveBeenCalledOnce(); + expect(media).toEqual({ + photo: { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/NEW', mediaType: 'image/jpeg' }, + logo: currentLogo, + }); + }); + + it('keeps a photo added elsewhere since, and drops a logo removed elsewhere, when the photo is removed', async () => { + const added: ContactMedia = { kind: 'photo', blobId: 'dbcAdded', mediaType: 'image/jpeg' }; + const loadCurrentMedia = vi.fn(async () => ({ media: { photo: photo1_0, added } })); + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByText('remove_photo')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + expect(onSave.mock.calls[0][0].media).toEqual({ added }); + }); + + it.each([ + ['cannot be read', async () => null], + ['fails to load', async () => { throw new Error('Request failed: 503'); }], + ])('falls back to the form\'s copy when the card %s', async (_why, loadCurrentMedia) => { + const media = await saveNewPhoto(loadCurrentMedia as () => Promise); + + expect(media).toEqual({ + photo: { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/NEW', mediaType: 'image/jpeg' }, + logo: staleLogo, + }); + }); + + it('reads nothing when the photo did not change', async () => { + const loadCurrentMedia = vi.fn(async () => ({ media: {} })); + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.change(screen.getByDisplayValue('+33612345678'), { target: { value: '+33699999999' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + expect(loadCurrentMedia).not.toHaveBeenCalled(); + expect('media' in onSave.mock.calls[0][0]).toBe(false); + }); + }); }); it('submits form data correctly', async () => { diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index c73e28714..ff814ce47 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -1,7 +1,10 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ContactList } from '../contact-list'; +import { exportContacts } from '../contact-export'; import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { BlobBatch } from '@/lib/jmap/client-interface'; import type { ContactCard } from '@/lib/jmap/types'; function makeContact(overrides: Partial & { id: string }): ContactCard { @@ -79,6 +82,40 @@ describe('ContactList', () => { expect(screen.getByText('bulk.export')).toBeInTheDocument(); }); + it('shows the bulk export as busy and takes no click while a vCard export runs', async () => { + // On Stalwart 1.0 the export first fetches every photo. + let deliver!: (batch: BlobBatch) => void; + useAuthStore.setState({ + client: { + getContactsAccountId: () => 'a', + getBlobsAsBase64: () => new Promise((resolve) => { deliver = resolve; }), + } as never, + }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:vcf'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const onBulkExport = vi.fn(); + try { + render(); + let running!: Promise; + act(() => { running = exportContacts([{ ...alice, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }]); }); + + const busy = screen.getByText('export.exporting').closest('button')!; + expect(busy).toBeDisabled(); + fireEvent.click(busy); + expect(onBulkExport).not.toHaveBeenCalled(); + + await act(async () => { + deliver({ data: new Map([['dbcX', '/9j/']]), failed: [] }); + await running; + }); + expect(screen.getByText('bulk.export').closest('button')).not.toBeDisabled(); + } finally { + useAuthStore.setState({ client: null }); + vi.restoreAllMocks(); + } + }); + it.each([ ['1.0', { kind: 'photo' as const, blobId: 'dbcX', mediaType: 'image/jpeg' }], ['0.16', { kind: 'photo' as const, uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }], diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index fdf0ebc54..d8c85ec68 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -21,7 +21,7 @@ import { } from "@/components/icons"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactPrimaryEmail } from "@/stores/contact-store"; -import { exportContact } from "./contact-export"; +import { exportContact, useContactExportBusy } from "./contact-export"; import { printContact } from "./contact-print"; import { toast } from "@/stores/toast-store"; @@ -74,6 +74,7 @@ export function ContactContextMenu({ const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); const showBatchActions = isMultiSelect && selectedCount > 1; + const exporting = useContactExportBusy(); const handle = (fn: () => void) => () => { fn(); @@ -101,7 +102,9 @@ export function ContactContextMenu({ }; const handleExport = () => { - void exportContact(contact).then(() => toast.success(t("export.success", { count: 1 }))); + void exportContact(contact).then((exported) => { + if (exported) toast.success(t("export.success", { count: 1 })); + }); }; const handlePrint = () => { @@ -124,7 +127,7 @@ export function ContactContextMenu({ icon={Download} label={t("bulk.export")} onClick={handle(() => onBatchExport?.())} - disabled={!onBatchExport} + disabled={!onBatchExport || exporting} /> void; destructive?: boolean; + disabled?: boolean; separator?: false; } | { separator: true }; @@ -122,6 +123,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; + const exporting = useContactExportBusy(); if (!contact) { return ( @@ -138,8 +140,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined; const handleExport = async () => { - await exportContact(contact); - toast.success(t("export.success", { count: 1 })); + if (await exportContact(contact)) toast.success(t("export.success", { count: 1 })); }; const handlePrint = () => { @@ -153,7 +154,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli if (onDuplicate) { moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate }); } - moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport }); + moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting }); moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint }); moreItems.push({ separator: true }); moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true }); @@ -593,12 +594,14 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) key={i} type="button" role="menuitem" + disabled={item.disabled} onClick={() => { item.onClick(); setOpen(false); }} className={cn( "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors", + "disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent", item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950", )} > diff --git a/components/contacts/contact-export.tsx b/components/contacts/contact-export.tsx index bb466cda1..ba67c4988 100644 --- a/components/contacts/contact-export.tsx +++ b/components/contacts/contact-export.tsx @@ -1,35 +1,58 @@ "use client"; +import { useSyncExternalStore } from "react"; import { generateVCard } from "@/lib/vcard"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; -import { hasBlobMedia, inlineContactMedia } from "@/lib/contact-media"; +import { inlineContactMediaForExport } from "@/lib/contact-media"; import { getContactPhotoClient } from "@/hooks/use-contact-photo"; -export async function exportContact(contact: ContactCard): Promise { - const vcf = generateVCard([await withInlineMedia(contact)]); - const name = getContactDisplayName(contact) || "contact"; - downloadVcf(vcf, `${sanitizeFilename(name)}.vcf`); +// On Stalwart 1.0 an export first downloads every photo, which takes a while +// for a large address book; a second click meanwhile would start it all over. +let exporting = false; +const busyListeners = new Set<() => void>(); + +function setExporting(value: boolean): void { + exporting = value; + for (const listener of busyListeners) listener(); } -export async function exportContacts(contacts: ContactCard[]): Promise { - const vcf = generateVCard(await Promise.all(contacts.map(withInlineMedia))); - downloadVcf(vcf, `contacts-${new Date().toISOString().slice(0, 10)}.vcf`); +function subscribeExporting(listener: () => void): () => void { + busyListeners.add(listener); + return () => { + busyListeners.delete(listener); + }; +} + +/** Whether a vCard export is running; the export buttons show it and wait. */ +export function useContactExportBusy(): boolean { + return useSyncExternalStore(subscribeExporting, () => exporting, () => false); } /** - * The card with its blob media (Stalwart 1.0) downloaded into data: URIs, - * which a vCard carries inline. A photo that cannot be downloaded is left out - * rather than failing the export. + * Builds the .vcf and hands it to the browser. Resolves false, doing nothing, + * while another export is still running. */ -async function withInlineMedia(contact: ContactCard): Promise { - if (!hasBlobMedia(contact.media)) return contact; - const media = await inlineContactMedia(contact.media, { - client: getContactPhotoClient(contact.localAccountId), - accountId: contact.accountId, - localAccountId: contact.localAccountId, - }, "drop"); - return { ...contact, media }; +async function runExport(contacts: ContactCard[], filename: string): Promise { + if (exporting) return false; + setExporting(true); + try { + // Blob media (Stalwart 1.0) become data: URIs, which a vCard carries inline. + const cards = await inlineContactMediaForExport(contacts, getContactPhotoClient); + downloadVcf(generateVCard(cards), filename); + return true; + } finally { + setExporting(false); + } +} + +export function exportContact(contact: ContactCard): Promise { + const name = getContactDisplayName(contact) || "contact"; + return runExport([contact], `${sanitizeFilename(name)}.vcf`); +} + +export function exportContacts(contacts: ContactCard[]): Promise { + return runExport(contacts, `contacts-${new Date().toISOString().slice(0, 10)}.vcf`); } function downloadVcf(content: string, filename: string) { diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 6a82c5317..d94fee994 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -58,6 +58,12 @@ interface ContactFormProps { /** `options.photoKey` names the `media` entry holding a newly picked photo. */ onSave: (data: Partial, options?: ContactSaveOptions) => Promise; onCancel: () => void; + /** + * Reads the card as the server has it now, or null when it can't. When the + * photo changed, `media` is rebuilt on it instead of on `contact`, which + * may be older. + */ + loadCurrentMedia?: () => Promise | null>; } /** What the user did to the photo: nothing, picked a new one (a data: URI), or removed it. */ @@ -73,6 +79,47 @@ function freeMediaKey(media: Record): string { return key; } +/** + * The `media` map to send for a photo edit, built on `base`: the card's media + * as the server has it now when it could be read, else the form's copy + * (`known`). `shownKey` is the photo the form showed. + * + * - A new photo takes the shown one's key and place, with its `pref`, + * `label` and `contexts`, so on a card with several photos it is still the + * one shown (the lowest pref wins). + * - Removing the photo removes every photo the form's copy had, not only the + * shown one: the next would otherwise take its place after the save, while + * the form showed none. Logos and sounds stay; so do photos added + * elsewhere since. + */ +function mediaForPhotoEdit( + base: Record, + known: Record, + shownKey: string | undefined, + edit: Exclude, +): { media: Record | null; photoKey?: string } { + if (edit.kind === "removed") { + const others = Object.fromEntries(Object.entries(base).filter( + ([key, entry]) => !(entry?.kind === "photo" && known[key]?.kind === "photo"), + )); + return { media: Object.keys(others).length > 0 ? others : null }; + } + const shown = shownKey ? (base[shownKey]?.kind === "photo" ? base[shownKey] : known[shownKey]) : undefined; + const photoKey = shownKey && (!base[shownKey] || base[shownKey]?.kind === "photo") ? shownKey : freeMediaKey(base); + const photo: ContactMedia = { + kind: "photo", + uri: edit.uri, + mediaType: edit.mediaType, + ...(shown?.pref !== undefined ? { pref: shown.pref } : {}), + ...(shown?.label !== undefined ? { label: shown.label } : {}), + ...(shown?.contexts !== undefined ? { contexts: shown.contexts } : {}), + }; + const media: Record = {}; + for (const [key, entry] of Object.entries(base)) media[key] = key === photoKey ? photo : entry; + media[photoKey] = photo; + return { media, photoKey }; +} + function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: { icon: React.ComponentType<{ className?: string }>; title: string; @@ -162,7 +209,7 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; @@ -559,23 +606,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress }); } - // `media` goes out only when the photo changed. An update without it keeps - // every entry on the server, while a map replaces them all, and `null` - // deletes them. The other entries go back verbatim: a data: URI on 0.16, a - // blobId on 1.0, which resolves it again. - let mediaValue: Record | null | undefined; - let photoKey: string | undefined; - if (photoEdit.kind === "new" || (photoEdit.kind === "removed" && initialPhotoKey)) { - const others: Record = { ...(contact?.media ?? {}) }; - if (initialPhotoKey) delete others[initialPhotoKey]; - if (photoEdit.kind === "new") { - photoKey = initialPhotoKey ?? freeMediaKey(others); - mediaValue = { ...others, [photoKey]: { kind: "photo", uri: photoEdit.uri, mediaType: photoEdit.mediaType } }; - } else { - mediaValue = Object.keys(others).length > 0 ? others : null; - } - } - // Only send `kind` when this form owns the answer: switching a card between // person and organization. Leave other kinds (group, location, ...) untouched. const kindValue: ContactCard["kind"] | undefined = @@ -608,13 +638,27 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined, - // Absent, not undefined: contactToWire sends an undefined key as null. - ...(mediaValue !== undefined ? { media: mediaValue as Record } : {}), ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; setIsSaving(true); try { + // `media` goes out only when the photo changed. An update without it + // keeps every entry on the server, while a map replaces them all, and + // `null` deletes them. The other entries go back as the server has them + // now: a data: URI on 0.16, a blobId on 1.0, which resolves it again. A + // blobId from an older copy of the card, say of a logo changed + // elsewhere since, makes 1.0 refuse the whole update ("The following + // blobIds do not exist or are not accessible"). + let photoKey: string | undefined; + if (photoEdit.kind === "new" || (photoEdit.kind === "removed" && initialPhotoKey)) { + const current = contact && loadCurrentMedia ? await loadCurrentMedia().catch(() => null) : null; + const known = contact?.media ?? {}; + const edited = mediaForPhotoEdit(current ? current.media ?? {} : known, known, initialPhotoKey, photoEdit); + // Absent, not undefined: contactToWire sends an undefined key as null. + data.media = edited.media as Record; + photoKey = edited.photoKey; + } await (photoKey ? onSave(data, { photoKey }) : onSave(data)); } catch (err) { setError(err instanceof Error ? err.message : t("save_failed")); diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 9a369040a..fed66a5cc 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,11 +2,12 @@ import { useMemo, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; -import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ } from "@/components/icons"; +import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2 } from "@/components/icons"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; import { ContactContextMenu } from "./contact-context-menu"; +import { useContactExportBusy } from "./contact-export"; import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; @@ -147,6 +148,7 @@ export function ContactList({ const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const activeFilters = countActiveFilters(filters); + const exporting = useContactExportBusy(); const monthNames = useMemo(() => { const fmt = new Intl.DateTimeFormat(locale, { month: "long" }); @@ -505,9 +507,11 @@ export function ContactList({ {t("bulk.add_to_group")} - diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx index 680936e66..ff000fbe5 100644 --- a/components/ui/__tests__/avatar.test.tsx +++ b/components/ui/__tests__/avatar.test.tsx @@ -61,6 +61,7 @@ describe('Avatar', () => { useAuthStore.setState({ client: null }); useContactStore.setState({ contacts: [] }); clearContactPhotoCache(); + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -117,15 +118,40 @@ describe('Avatar', () => { expect(fetchBlob).toHaveBeenCalledTimes(1); }); - it('shows initials when the download fails', async () => { + it('shows initials when the download fails, after trying once more', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404')); const { container } = render(); - await waitFor(() => expect(fetchBlob).toHaveBeenCalled()); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(fetchBlob).toHaveBeenCalledTimes(1); + // The cache asks again after a second, since the server refuses a + // download over its concurrency limit outright; then it gives up. + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(fetchBlob).toHaveBeenCalledTimes(2); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + + expect(fetchBlob).toHaveBeenCalledTimes(2); expect(img(container)).toBeNull(); expect(container.textContent).toBe('AL'); }); + it.each([ + [4, 2], + [8, 4], + ])('downloads photos %i / 2 = %i at a time for a server with that maxConcurrentRequests', async (limit, atOnce) => { + const pending: Array<() => void> = []; + fetchBlob.mockImplementation(() => new Promise((resolve) => pending.push(() => resolve(jpeg())))); + useAuthStore.setState({ client: { fetchBlob, getMaxConcurrentRequests: () => limit } as never }); + + render(<>{[0, 1, 2, 3, 4, 5].map((i) => )}); + await act(async () => {}); + + expect(fetchBlob).toHaveBeenCalledTimes(atOnce); + clearContactPhotoCache(); + pending.forEach((resolve) => resolve()); + }); + it('falls back to initials when a downloaded photo does not render', async () => { const { container } = render(); await waitFor(() => expect(img(container)).not.toBeNull()); @@ -154,6 +180,33 @@ describe('Avatar', () => { } }); + it("asks no other server when the card's login has no client, and tries again once it has one", async () => { + const original = useAuthStore.getState().getClientForAccount; + const otherFetch = vi.fn(async () => jpeg()); + let connected = false; + useAuthStore.setState({ + getClientForAccount: (id: string) => (connected && id === 'other@host' ? { fetchBlob: otherFetch } as never : undefined), + }); + try { + const { container } = render( + , + ); + await act(async () => {}); + // The active client belongs to another login, maybe another server. + expect(fetchBlob).not.toHaveBeenCalled(); + expect(container.textContent).toBe('AL'); + + // Nothing was remembered as a failure: once connected, the photo comes. + connected = true; + act(() => useAuthStore.setState({ client: { fetchBlob } as never })); + await waitFor(() => expect(img(container)?.getAttribute('src')).toBe('blob:photo')); + expect(otherFetch).toHaveBeenCalledTimes(1); + expect(fetchBlob).not.toHaveBeenCalled(); + } finally { + useAuthStore.setState({ getClientForAccount: original }); + } + }); + it('downloads nothing when images are disabled', () => { render(); expect(fetchBlob).not.toHaveBeenCalled(); diff --git a/hooks/__tests__/use-contact-photo.test.ts b/hooks/__tests__/use-contact-photo.test.ts new file mode 100644 index 000000000..2e3f31ab8 --- /dev/null +++ b/hooks/__tests__/use-contact-photo.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { getContactPhotoClient } from '../use-contact-photo'; +import { useAuthStore } from '@/stores/auth-store'; + +describe('getContactPhotoClient', () => { + const active = { name: 'active' } as never; + const other = { name: 'other' } as never; + const original = useAuthStore.getState().getClientForAccount; + + afterEach(() => { + useAuthStore.setState({ client: null, getClientForAccount: original }); + }); + + it('uses the active client for a card without a login (single account)', () => { + useAuthStore.setState({ client: active }); + expect(getContactPhotoClient(undefined)).toBe(active); + }); + + it("uses the client of the card's login in multi-account mode", () => { + useAuthStore.setState({ + client: active, + getClientForAccount: (id: string) => (id === 'other@host' ? other : undefined), + }); + expect(getContactPhotoClient('other@host')).toBe(other); + }); + + it("is null, not the active client, when the card's login has no client", () => { + // The active client may talk to another server: the download would fail + // there and the photo stay blank as a remembered failure. + useAuthStore.setState({ client: active, getClientForAccount: () => undefined }); + expect(getContactPhotoClient('gone@host')).toBeNull(); + }); +}); diff --git a/hooks/use-contact-photo.ts b/hooks/use-contact-photo.ts index af8029dc4..a7ff054d3 100644 --- a/hooks/use-contact-photo.ts +++ b/hooks/use-contact-photo.ts @@ -6,16 +6,22 @@ import { useAuthStore } from "@/stores/auth-store"; import { getClientByLocalAccountId } from "@/stores/client-registry"; import type { ContactPhotoRef } from "@/stores/contact-store"; import { + contactPhotoConcurrency, contactPhotoKey, peekContactPhoto, requestContactPhoto, subscribeContactPhotos, } from "@/lib/contact-photo-cache"; -/** The client that downloads blobs of a card from the given login (multi-account mode), else the active one. */ +/** + * The client that downloads blobs of a card: the one of the card's login in + * multi-account mode, else the active one. Null when that login has no + * client (signed out, not connected yet): the active client would ask another + * server, and its refusal would stick to the photo as a failure. + */ export function getContactPhotoClient(localAccountId?: string): IJMAPClient | null { - const active = useAuthStore.getState().client; - return (localAccountId ? getClientByLocalAccountId(localAccountId) : undefined) ?? active; + if (localAccountId) return getClientByLocalAccountId(localAccountId) ?? null; + return useAuthStore.getState().client; } const visibilityCallbacks = new Map void>(); @@ -55,6 +61,8 @@ export function useContactPhotoSrc(photo: ContactPhotoRef | null | undefined): { () => (key ? peekContactPhoto(key) : undefined), () => undefined, ); + // Asks again once a client comes along (sign-in, account switch). + const activeClient = useAuthStore((s) => s.client); const [element, setElement] = useState(null); const [visible, setVisible] = useState(false); @@ -69,14 +77,15 @@ export function useContactPhotoSrc(photo: ContactPhotoRef | null | undefined): { const localAccountId = blob?.localAccountId; useEffect(() => { if (!key || !blobId || !visible) return; + const client = getContactPhotoClient(localAccountId); + if (!client) return; // Also re-runs when the cached copy goes (evicted, cache cleared) while // the avatar is still on screen. return requestContactPhoto(key, async () => { - const client = getContactPhotoClient(localAccountId); - if (!client) throw new Error("Not connected"); - return client.fetchBlob(blobId, "photo", mediaType, accountId); - }); - }, [key, blobId, mediaType, accountId, localAccountId, visible, blobUrl]); + const current = getContactPhotoClient(localAccountId); + return current ? current.fetchBlob(blobId, "photo", mediaType, accountId) : null; + }, { concurrency: contactPhotoConcurrency(client.getMaxConcurrentRequests?.()) }); + }, [key, blobId, mediaType, accountId, localAccountId, visible, blobUrl, activeClient]); return { src: photo && "uri" in photo ? photo.uri : blobUrl, observe: setElement }; } diff --git a/lib/__tests__/contact-media.test.ts b/lib/__tests__/contact-media.test.ts index d94916f3a..218eaddaa 100644 --- a/lib/__tests__/contact-media.test.ts +++ b/lib/__tests__/contact-media.test.ts @@ -5,10 +5,11 @@ import { clearContactPhotoCache } from '../contact-photo-cache'; import { hasBlobMedia, inlineContactMedia, + inlineContactMediaForExport, isBlobMediaRejection, writeContactWithNewPhoto, } from '../contact-media'; -import type { IJMAPClient } from '../jmap/client-interface'; +import type { BlobBatch, IJMAPClient } from '../jmap/client-interface'; import type { ContactCard, ContactMedia } from '../jmap/types'; // The blob capability as each version advertises it (common/src/config/mailstore/capabilities.rs). @@ -213,12 +214,25 @@ describe('isBlobMediaRejection', () => { expect(isBlobMediaRejection(jmapError(REFUSED_0_16.description, REFUSED_0_16))).toBe(true); }); + it('also counts a refusal that names an entry below media, whatever it says', () => { + const entry = { type: 'invalidProperties', properties: ['media/photo'], description: 'Unsupported media.' }; + const field = { type: 'invalidProperties', properties: ['name', 'media/photo/blobId'] }; + expect(isBlobMediaRejection(jmapError(entry.description, entry))).toBe(true); + expect(isBlobMediaRejection(jmapError('Invalid', field))).toBe(true); + }); + it('leaves out an unresolvable blobId on Stalwart 1.0, which names no property', () => { // blob/embedded.rs:425-437 const unresolved = { type: 'invalidProperties', description: 'The following blobIds do not exist or are not accessible: dbcX.' }; expect(isBlobMediaRejection(jmapError(unresolved.description, unresolved))).toBe(false); expect(isBlobMediaRejection(new Error('network'))).toBe(false); }); + + it('leaves out refusals of other properties', () => { + const other = { type: 'invalidProperties', properties: ['mediaType', 'emails/e0/address'] }; + expect(isBlobMediaRejection(jmapError('Invalid', other))).toBe(false); + expect(isBlobMediaRejection(jmapError('Too large', { type: 'tooLarge', properties: ['media'] }))).toBe(false); + }); }); describe('inlineContactMedia', () => { @@ -255,15 +269,23 @@ describe('inlineContactMedia', () => { }); it('drops a blob entry whose download fails, or keeps it as it is', async () => { - const client = { fetchBlob: vi.fn().mockRejectedValue(new Error('Failed to fetch blob: 404')) } as unknown as IJMAPClient; + const fetchBlob = vi.fn().mockRejectedValue(new Error('Failed to fetch blob: 404')); + const client = { fetchBlob } as unknown as IJMAPClient; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const dropped = inlineContactMedia({ photo: photo1_0, logo: LOGO_1_0 }, { client }, 'drop'); await vi.advanceTimersByTimeAsync(1000); expect(await dropped).toEqual({}); + // Each blob was tried twice: once, and once more a second later. + expect(fetchBlob).toHaveBeenCalledTimes(4); + // The cache would answer from its memory of the failure without asking. + clearContactPhotoCache(); + fetchBlob.mockClear(); const kept = inlineContactMedia({ photo: photo1_0 }, { client }, 'keep'); + await vi.advanceTimersByTimeAsync(1000); expect(await kept).toEqual({ photo: photo1_0 }); + expect(fetchBlob).toHaveBeenCalledTimes(2); }); it('knows which media still need downloading', () => { @@ -272,3 +294,117 @@ describe('inlineContactMedia', () => { expect(hasBlobMedia(undefined)).toBe(false); }); }); + +describe('inlineContactMediaForExport', () => { + beforeEach(() => { + clearContactPhotoCache(); + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:x'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + }); + + afterEach(() => { + clearContactPhotoCache(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const card = (id: string, media: ContactCard['media'], extra: Partial = {}): ContactCard => + ({ id, addressBookIds: {}, name: { full: id }, media, ...extra }); + const jpeg = () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }); + + function exportClient(batch: BlobBatch | null | Error | undefined) { + return { + getContactsAccountId: () => 'a', + fetchBlob: vi.fn(async () => jpeg()), + ...(batch === undefined ? {} : { + getBlobsAsBase64: vi.fn(async () => { + if (batch instanceof Error) throw batch; + return batch; + }), + }), + }; + } + + it('fetches the blob media of every card in one batch on Stalwart 1.0', async () => { + const client = exportClient({ data: new Map([['dbcA', '/9j/A'], ['dbcLogo', 'iVBO'], ['dbcB', '/9j/B']]), failed: [] }); + const legacy: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }; + + const cards = await inlineContactMediaForExport([ + card('1', { photo: { kind: 'photo', blobId: 'dbcA', mediaType: 'image/jpeg', pref: 1 }, logo: LOGO_1_0 }), + card('2', { photo: { kind: 'photo', blobId: 'dbcB', mediaType: 'image/jpeg' } }), + card('3', { photo: legacy }), + card('4', undefined), + ], () => client as unknown as IJMAPClient); + + expect(client.getBlobsAsBase64).toHaveBeenCalledTimes(1); + expect(client.getBlobsAsBase64).toHaveBeenCalledWith(['dbcA', 'dbcLogo', 'dbcB'], 'a'); + expect(client.fetchBlob).not.toHaveBeenCalled(); + expect(cards.map((c) => c.media)).toEqual([ + { + photo: { kind: 'photo', mediaType: 'image/jpeg', pref: 1, uri: 'data:image/jpeg;base64,/9j/A' }, + logo: { kind: 'logo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBO' }, + }, + { photo: { kind: 'photo', mediaType: 'image/jpeg', uri: 'data:image/jpeg;base64,/9j/B' } }, + { photo: legacy }, + undefined, + ]); + }); + + it('asks each account and login for its own blobs', async () => { + const own = exportClient({ data: new Map([['dbcA', 'QQ==']]), failed: [] }); + const other = exportClient({ data: new Map([['dbcB', 'Qg==']]), failed: [] }); + const clientFor = vi.fn((login?: string) => (login === 'other@host' ? other : own) as unknown as IJMAPClient); + + const cards = await inlineContactMediaForExport([ + card('1', { photo: { kind: 'photo', blobId: 'dbcA', mediaType: 'image/jpeg' } }, { accountId: 'shared-1' }), + card('2', { photo: { kind: 'photo', blobId: 'dbcB', mediaType: 'image/jpeg' } }, { localAccountId: 'other@host' }), + ], clientFor); + + expect(own.getBlobsAsBase64).toHaveBeenCalledWith(['dbcA'], 'shared-1'); + expect(other.getBlobsAsBase64).toHaveBeenCalledWith(['dbcB'], 'a'); + expect(clientFor).toHaveBeenCalledWith('other@host'); + expect(cards.map((c) => c.media?.photo.uri)).toEqual(['data:image/jpeg;base64,QQ==', 'data:image/jpeg;base64,Qg==']); + }); + + it.each([ + ['the session lacks the blob capability', null], + ['the batch request fails', new Error('Request failed: 500')], + ['the client cannot batch', undefined], + ])('downloads the blobs one by one when %s', async (_why, batch) => { + const client = exportClient(batch as BlobBatch | null | Error | undefined); + + const [exported] = await inlineContactMediaForExport([ + card('1', { photo: { kind: 'photo', blobId: 'dbcA', mediaType: 'image/jpeg' } }, { accountId: 'a' }), + ], () => client as unknown as IJMAPClient); + + expect(client.fetchBlob).toHaveBeenCalledWith('dbcA', 'photo', 'image/jpeg', 'a'); + expect(exported.media?.photo.uri).toBe('data:image/jpeg;base64,/9j/'); + }); + + it('downloads only what the batch left unfetched, and leaves out what the server no longer has', async () => { + // dbcGone is in neither list: Blob/get reported it notFound. + const client = exportClient({ data: new Map([['dbcA', 'QQ==']]), failed: ['dbcB'] }); + + const cards = await inlineContactMediaForExport([ + card('1', { photo: { kind: 'photo', blobId: 'dbcA', mediaType: 'image/jpeg' } }), + card('2', { photo: { kind: 'photo', blobId: 'dbcB', mediaType: 'image/jpeg' } }), + card('3', { photo: { kind: 'photo', blobId: 'dbcGone', mediaType: 'image/jpeg' } }), + ], () => client as unknown as IJMAPClient); + + expect(client.fetchBlob).toHaveBeenCalledTimes(1); + expect(client.fetchBlob).toHaveBeenCalledWith('dbcB', 'photo', 'image/jpeg', undefined); + expect(cards.map((c) => c.media)).toEqual([ + { photo: { kind: 'photo', mediaType: 'image/jpeg', uri: 'data:image/jpeg;base64,QQ==' } }, + { photo: { kind: 'photo', mediaType: 'image/jpeg', uri: 'data:image/jpeg;base64,/9j/' } }, + {}, + ]); + }); + + it('leaves the photos out when the card\'s login has no client', async () => { + const cards = await inlineContactMediaForExport([ + card('1', { photo: { kind: 'photo', blobId: 'dbcA' }, logo: { kind: 'logo', uri: 'https://x.test/l.png' } }, { localAccountId: 'gone@host' }), + ], () => null); + + expect(cards[0].media).toEqual({ logo: { kind: 'logo', uri: 'https://x.test/l.png' } }); + }); +}); diff --git a/lib/__tests__/contact-photo-cache.test.ts b/lib/__tests__/contact-photo-cache.test.ts index 363503871..73c6d53ec 100644 --- a/lib/__tests__/contact-photo-cache.test.ts +++ b/lib/__tests__/contact-photo-cache.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { clearContactPhotoCache, + contactPhotoConcurrency, contactPhotoKey, loadContactPhotoBlob, peekContactPhoto, @@ -59,7 +60,7 @@ describe('contact photo cache', () => { expect(listener).toHaveBeenCalled(); }); - it('runs at most two downloads at a time, so JMAP calls keep two of the four slots', async () => { + it("runs at most two downloads at a time by default, so JMAP calls keep two of Stalwart's four slots", async () => { const pending = [deferred(), deferred(), deferred(), deferred()]; const fetchers = pending.map((d) => vi.fn(() => d.promise)); fetchers.forEach((fetcher, i) => requestContactPhoto(`|b${i}`, fetcher)); @@ -78,6 +79,101 @@ describe('contact photo cache', () => { await flush(); }); + it("takes half of the server's maxConcurrentRequests, and at least one", () => { + // Stalwart 0.16 and 1.0 both advertise 4 (core capability). + expect(contactPhotoConcurrency(4)).toBe(2); + expect(contactPhotoConcurrency(8)).toBe(4); + expect(contactPhotoConcurrency(3)).toBe(1); + expect(contactPhotoConcurrency(1)).toBe(1); + expect(contactPhotoConcurrency(undefined)).toBe(2); + }); + + it("runs as many downloads at once as the server's share allows", async () => { + const pending = Array.from({ length: 6 }, () => deferred()); + const fetchers = pending.map((d) => vi.fn(() => d.promise)); + fetchers.forEach((fetcher, i) => requestContactPhoto(`|c${i}`, fetcher, { concurrency: 4 })); + + expect(fetchers.map((f) => f.mock.calls.length)).toEqual([1, 1, 1, 1, 0, 0]); + + pending.forEach((d) => d.resolve(jpeg())); + await flush(); + expect(fetchers.every((f) => f.mock.calls.length === 1)).toBe(true); + }); + + it("starts an avatar's download before the ones only an export asked for", async () => { + const exports = [deferred(), deferred()]; + const exported = [...exports.map((d) => vi.fn(() => d.promise)), vi.fn(() => Promise.resolve(jpeg()))]; + exported.forEach((fetcher, i) => void loadContactPhotoBlob(`|e${i}`, fetcher, { background: true })); + expect(exported.map((f) => f.mock.calls.length)).toEqual([1, 1, 0]); + + const avatar = deferred(); + const shown = vi.fn(() => avatar.promise); + requestContactPhoto('|a0', shown); + exports[0].resolve(jpeg()); + await flush(); + expect(shown).toHaveBeenCalledTimes(1); + expect(exported[2]).not.toHaveBeenCalled(); + + avatar.resolve(jpeg()); + await flush(); + expect(exported[2]).toHaveBeenCalledTimes(1); + expect(peekContactPhoto('|a0')).toBeDefined(); + exports[1].resolve(jpeg()); + await flush(); + }); + + it('moves a photo an export queued up once an avatar wants it too', async () => { + const busy = [deferred(), deferred()]; + busy.forEach((d, i) => requestContactPhoto(`|busy${i}`, () => d.promise)); + const started: string[] = []; + const fetcherFor = (name: string) => () => { + started.push(name); + return Promise.resolve(jpeg()); + }; + const exported = loadContactPhotoBlob('|x0', fetcherFor('export only'), { background: true }); + void loadContactPhotoBlob('|x1', fetcherFor('export and avatar'), { background: true }); + requestContactPhoto('|x1', fetcherFor('export and avatar')); + + busy[0].resolve(jpeg()); + busy[1].resolve(jpeg()); + expect(await exported).not.toBeNull(); + + expect(started).toEqual(['export and avatar', 'export only']); + expect(peekContactPhoto('|x1')).toBeDefined(); + }); + + it('leaves part of the burst to avatars while an export downloads', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const exported = vi.fn(() => Promise.resolve(jpeg())); + for (let i = 0; i < 30; i++) void loadContactPhotoBlob(`|x${i}`, exported, { background: true }); + await flush(); + expect(exported).toHaveBeenCalledTimes(20); + + const shown = vi.fn(() => Promise.resolve(jpeg())); + for (let i = 0; i < 20; i++) requestContactPhoto(`|v${i}`, shown); + await flush(); + expect(shown).toHaveBeenCalledTimes(20); + expect(exported).toHaveBeenCalledTimes(20); + }); + + it('neither retries nor remembers a download that had no client to ask', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const fetcher = vi.fn(async () => null); + + requestContactPhoto('|offline', fetcher); + await flush(); + vi.advanceTimersByTime(1000); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(peekContactPhoto('|offline')).toBeUndefined(); + + // Asked again once there is a client: not held back as a failure. + requestContactPhoto('|offline', fetcher); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(await loadContactPhotoBlob('|offline', fetcher)).toBeNull(); + }); + it('drops a queued download once no avatar on screen wants it', async () => { const first = deferred(); const second = deferred(); @@ -197,6 +293,21 @@ describe('contact photo cache', () => { expect(createObjectURL).toHaveBeenCalledTimes(1); }); + it('frees the slots of downloads still running for the account before a switch', async () => { + const hung = [deferred(), deferred()]; + hung.forEach((d, i) => requestContactPhoto(`|old${i}`, () => d.promise)); + clearContactPhotoCache(); + + const fetcher = vi.fn(() => Promise.resolve(jpeg())); + requestContactPhoto('|new', fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + + hung.forEach((d) => d.resolve(jpeg())); + await flush(); + expect(peekContactPhoto('|old0')).toBeUndefined(); + expect(peekContactPhoto('|new')).toBeDefined(); + }); + it('gives print and export the bytes, from the cache when it has them', async () => { const shown = jpeg('shown'); requestContactPhoto('|shown', () => Promise.resolve(shown)); diff --git a/lib/__tests__/jmap-blob-get.test.ts b/lib/__tests__/jmap-blob-get.test.ts new file mode 100644 index 000000000..547b89c0d --- /dev/null +++ b/lib/__tests__/jmap-blob-get.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// Blob/get (RFC 9404) as Stalwart 1.0 answers it (jmap/src/blob/get.rs): the +// id always comes back, `size` is the whole blob, and ids it can't read go +// to notFound. A vCard export of a large address book fetches its photos +// this way instead of one download each. + +const MiB = 1024 * 1024; +const BLOB = 'urn:ietf:params:jmap:blob'; + +function makeSession(options: { blob?: boolean; maxObjectsInGet?: number } = {}) { + const { blob = true, maxObjectsInGet = 500 } = options; + return { + capabilities: { + 'urn:ietf:params:jmap:core': { maxObjectsInGet, maxCallsInRequest: 16, maxConcurrentRequests: 4 }, + ...(blob ? { [BLOB]: {} } : {}), + }, + accounts: { + a: { + name: 'user@test.com', + isPersonal: true, + accountCapabilities: blob + ? { [BLOB]: { supportedTypeNames: ['Email', 'Thread', 'SieveScript', 'CalendarEvent', 'ContactCard'] } } + : {}, + }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + apiUrl: 'https://mail.example.com/jmap/', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +type Call = [string, { accountId: string; ids: string[]; properties: string[] }, string]; + +describe('JMAPClient.getBlobsAsBase64', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function connectedClient(options?: Parameters[0]): Promise { + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(options))); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + /** A 1.0 server holding `blobs` (id -> size); records every request. */ + function serve(blobs: Record, refuse?: (call: Call, index: number) => Response | undefined) { + const requests: Array<{ using: string[]; call: Call }> = []; + fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string); + const call = body.methodCalls[0] as Call; + requests.push({ using: body.using, call }); + const refused = refuse?.(call, requests.length - 1); + if (refused) return refused; + const [, args, callId] = call; + const found = args.ids.filter((id) => id in blobs); + return jsonResponse({ + methodResponses: [['Blob/get', { + accountId: args.accountId, + list: found.map((id) => args.properties.includes('size') + ? { size: blobs[id], id } + : { 'data:asBase64': btoa(id), id }), + notFound: args.ids.filter((id) => !(id in blobs)), + }, callId]], + }); + }) as never); + return requests; + } + + it('fetches the photos of a card with one Blob/get on Stalwart 1.0', async () => { + const client = await connectedClient(); + const requests = serve({ dbcPhoto: 40_000, dbcLogo: 2_000 }); + + const batch = await client.getBlobsAsBase64(['dbcPhoto', 'dbcLogo', 'dbcPhoto'], 'shared-1'); + + expect(requests).toHaveLength(1); + expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core', BLOB]); + expect(requests[0].call[0]).toBe('Blob/get'); + expect(requests[0].call[1]).toEqual({ accountId: 'shared-1', ids: ['dbcPhoto', 'dbcLogo'], properties: ['data:asBase64'] }); + expect(batch).toEqual({ data: new Map([['dbcPhoto', btoa('dbcPhoto')], ['dbcLogo', btoa('dbcLogo')]]), failed: [] }); + }); + + it('asks for the sizes first and fetches the data in requests of about 4 MiB', async () => { + const client = await connectedClient(); + const requests = serve({ a: 3 * MiB, b: 2 * MiB, c: 2 * MiB, d: 5 * MiB, e: 1024 }); + + const batch = await client.getBlobsAsBase64(['a', 'b', 'gone', 'c', 'd', 'e']); + + expect(requests.map(({ call }) => [call[1].properties, call[1].ids])).toEqual([ + [['size'], ['a', 'b', 'gone', 'c', 'd', 'e']], + // A blob larger than the budget goes alone; what the server doesn't have is not asked for again. + [['data:asBase64'], ['a']], + [['data:asBase64'], ['b', 'c']], + [['data:asBase64'], ['d']], + [['data:asBase64'], ['e']], + ]); + expect(requests.every(({ call }) => call[1].accountId === 'a')).toBe(true); + expect([...batch!.data.keys()]).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(batch!.failed).toEqual([]); + }); + + it('keeps every request within maxObjectsInGet', async () => { + const client = await connectedClient({ maxObjectsInGet: 2 }); + const requests = serve({ a: 10, b: 10, c: 10 }); + + const batch = await client.getBlobsAsBase64(['a', 'b', 'c']); + + expect(requests.map(({ call }) => [call[1].properties[0], call[1].ids])).toEqual([ + ['size', ['a', 'b']], + ['size', ['c']], + ['data:asBase64', ['a', 'b']], + ['data:asBase64', ['c']], + ]); + expect(batch!.data.size).toBe(3); + }); + + it('asks nothing when the session does not advertise the blob capability', async () => { + const client = await connectedClient({ blob: false }); + + expect(await client.getBlobsAsBase64(['a'])).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('lists the blobs a refused request left unfetched, keeping what came before', async () => { + const client = await connectedClient(); + serve({ a: 3 * MiB, b: 3 * MiB, c: 3 * MiB }, (_call, index) => (index === 2 + ? jsonResponse({ methodResponses: [['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']] }) + : undefined)); + + const batch = await client.getBlobsAsBase64(['a', 'b', 'c']); + + expect([...batch!.data.keys()]).toEqual(['a']); + expect(batch!.failed).toEqual(['b', 'c']); + }); + + it('lists every blob as unfetched when the sizes cannot be read', async () => { + const client = await connectedClient(); + serve({ a: 10, b: 10, c: 10 }, () => jsonResponse({ type: 'about:blank', status: 500 }, 500)); + + const batch = await client.getBlobsAsBase64(['a', 'b', 'c']); + + expect(batch).toEqual({ data: new Map(), failed: ['a', 'b', 'c'] }); + }); +}); diff --git a/lib/__tests__/jmap-request-limits.test.ts b/lib/__tests__/jmap-request-limits.test.ts index c6743f062..b811f2332 100644 --- a/lib/__tests__/jmap-request-limits.test.ts +++ b/lib/__tests__/jmap-request-limits.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { JMAPClient } from '../jmap/client'; -import { batched, itemsPerRequest } from '../jmap/request-limits'; +import { batched, batchedBySize, itemsPerRequest } from '../jmap/request-limits'; // Stalwart allows 16 method calls and 500 objects per request by default. A // batch built from a list the user controls - tags, a multi-select, an import - @@ -53,6 +53,30 @@ describe('batched', () => { }); }); +describe('batchedBySize', () => { + const size = (n: number) => n; + + it('fills each batch up to the budget', () => { + expect(batchedBySize([3, 2, 2, 1, 4], size, 4, 10)).toEqual([[3], [2, 2], [1], [4]]); + }); + + it('gives an item over the budget a batch of its own', () => { + expect(batchedBySize([1, 9, 1], size, 4, 10)).toEqual([[1], [9], [1]]); + }); + + it('counts an item without a size as the whole budget', () => { + expect(batchedBySize(['a', 'b', 'c'], (item) => (item === 'b' ? undefined : 1), 4, 10)).toEqual([['a'], ['b'], ['c']]); + }); + + it('never puts more than maxCount items in a batch', () => { + expect(batchedBySize([1, 1, 1, 1, 1], size, 100, 2)).toEqual([[1, 1], [1, 1], [1]]); + }); + + it('returns nothing for an empty list', () => { + expect(batchedBySize([], size, 4, 10)).toEqual([]); + }); +}); + describe('itemsPerRequest', () => { it('divides the call budget by the cost of one item', () => { expect(itemsPerRequest(16, 2)).toBe(8); diff --git a/lib/contact-media.ts b/lib/contact-media.ts index 3bb15f5c4..b237544b0 100644 --- a/lib/contact-media.ts +++ b/lib/contact-media.ts @@ -2,7 +2,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; import { jmapErrorProperties, jmapErrorType } from '@/lib/jmap/jmap-error'; import { blobToDataUri, dataUriToBlob } from '@/lib/data-uri'; -import { contactPhotoKey, loadContactPhotoBlob } from '@/lib/contact-photo-cache'; +import { contactPhotoConcurrency, contactPhotoKey, loadContactPhotoBlob } from '@/lib/contact-photo-cache'; import { debug } from '@/lib/debug'; /** @@ -28,23 +28,54 @@ export interface ContactSaveOptions { photoKey?: string; } +type BlobMedia = ContactMedia & { blobId: string }; + export function hasBlobMedia(media: Record | undefined): boolean { return Object.values(media ?? {}).some((entry) => !!entry?.blobId && !entry.uri); } -/** The bytes of one blob media entry, through the photo cache's download queue. */ +/** + * The bytes of one blob media entry, through the photo cache's download queue. + * `background` puts it behind the avatars and print (an export). + */ export function loadContactMediaBlob( entry: Pick & { blobId: string }, source: ContactMediaSource, + options: { background?: boolean } = {}, ): Promise { const { client } = source; if (!client) return Promise.resolve(null); return loadContactPhotoBlob( contactPhotoKey(source.localAccountId, entry.blobId), () => client.fetchBlob(entry.blobId, entry.kind, entry.mediaType, source.accountId), + { concurrency: contactPhotoConcurrency(client.getMaxConcurrentRequests?.()), background: options.background }, ); } +/** `media` with each blob entry replaced by a data: URI from `uriOf`, or dropped/kept when it has none. */ +async function withInlineBlobs( + media: Record, + uriOf: (entry: BlobMedia) => Promise, + onFailure: 'drop' | 'keep', +): Promise> { + const entries = await Promise.all(Object.entries(media).map( + async ([key, entry]): Promise<[string, ContactMedia] | null> => { + if (!entry?.blobId) return [key, entry]; + const { blobId, ...rest } = entry; + if (entry.uri) return [key, rest]; + const uri = await uriOf({ ...entry, blobId }); + if (uri) return [key, { ...rest, uri }]; + return onFailure === 'keep' ? [key, entry] : null; + }, + )); + return Object.fromEntries(entries.filter((entry): entry is [string, ContactMedia] => entry !== null)); +} + +async function downloadAsDataUri(entry: BlobMedia, source: ContactMediaSource, background?: boolean): Promise { + const blob = await loadContactMediaBlob(entry, source, { background }); + return blob ? blobToDataUri(blob, entry.mediaType).catch(() => null) : null; +} + /** * `media` with every blob entry turned into a data: URI of its bytes. A vCard * carries the bytes themselves, and a copy of the card written elsewhere @@ -58,23 +89,83 @@ export async function inlineContactMedia( onFailure: 'drop' | 'keep', ): Promise | undefined> { if (!media) return media; - const entries = await Promise.all(Object.entries(media).map( - async ([key, entry]): Promise<[string, ContactMedia] | null> => { - if (!entry?.blobId) return [key, entry]; - const { blobId, ...rest } = entry; - if (entry.uri) return [key, rest]; - const blob = await loadContactMediaBlob({ ...entry, blobId }, source); - const uri = blob ? await blobToDataUri(blob, entry.mediaType).catch(() => null) : null; - if (uri) return [key, { ...rest, uri }]; - return onFailure === 'keep' ? [key, entry] : null; - }, - )); - return Object.fromEntries(entries.filter((entry): entry is [string, ContactMedia] => entry !== null)); + return withInlineBlobs(media, (entry) => downloadAsDataUri(entry, source), onFailure); } -/** 0.16's answer to a blobId inside `media`: invalidProperties naming `media`. */ +/** + * The cards with their blob media turned into data: URIs, for a vCard export. + * + * The blobs of all cards of one account come through a few batched Blob/get + * requests (getBlobsAsBase64). One download each through the photo queue + * took minutes for a large address book on Stalwart 1.0, and held up the + * avatars and print meanwhile. What the batch can't deliver (no blob + * capability, a refused request) is downloaded one by one behind avatars and + * print; a blob that can't be downloaded at all is left out of the export. + */ +export async function inlineContactMediaForExport( + contacts: ContactCard[], + clientFor: (localAccountId?: string) => IJMAPClient | null, +): Promise { + const sourceKey = (contact: ContactCard) => `${contact.localAccountId ?? ''}|${contact.accountId ?? ''}`; + const groups = new Map }>(); + for (const contact of contacts) { + if (!hasBlobMedia(contact.media)) continue; + const key = sourceKey(contact); + let group = groups.get(key); + if (!group) { + group = { + source: { client: clientFor(contact.localAccountId), accountId: contact.accountId, localAccountId: contact.localAccountId }, + entries: new Map(), + }; + groups.set(key, group); + } + for (const entry of Object.values(contact.media ?? {})) { + if (entry?.blobId && !entry.uri) group.entries.set(entry.blobId, entry as BlobMedia); + } + } + + const uris = new Map(); + for (const [key, { source, entries }] of groups) { + const { client } = source; + if (!client) continue; + let oneByOne = [...entries.values()]; + const batch = await client.getBlobsAsBase64?.([...entries.keys()], source.accountId || client.getContactsAccountId()) + .catch((error: unknown) => { + debug.warn('contacts', 'Batched download of contact media failed:', error); + return null; + }); + if (batch) { + for (const [blobId, base64] of batch.data) { + const entry = entries.get(blobId); + if (entry) uris.set(`${key}|${blobId}`, `data:${entry.mediaType || 'application/octet-stream'};base64,${base64}`); + } + const failed = new Set(batch.failed); + oneByOne = oneByOne.filter((entry) => failed.has(entry.blobId)); + } + await Promise.all(oneByOne.map(async (entry) => { + const uri = await downloadAsDataUri(entry, source, true); + if (uri) uris.set(`${key}|${entry.blobId}`, uri); + })); + } + + return Promise.all(contacts.map(async (contact) => { + if (!hasBlobMedia(contact.media)) return contact; + const key = sourceKey(contact); + const media = await withInlineBlobs(contact.media!, async (entry) => uris.get(`${key}|${entry.blobId}`) ?? null, 'drop'); + return { ...contact, media }; + })); +} + +/** + * The server refused a blobId inside `media`: invalidProperties naming + * `media` or an entry below it. 0.16 answers so ("blobIds in media is not + * supported.", properties `["media"]`); another server may name the entry. + * 1.0's "The following blobIds do not exist or are not accessible" names no + * property and is a different problem. + */ export function isBlobMediaRejection(error: unknown): boolean { - return jmapErrorType(error) === 'invalidProperties' && jmapErrorProperties(error).includes('media'); + return jmapErrorType(error) === 'invalidProperties' + && jmapErrorProperties(error).some((property) => property === 'media' || property.startsWith('media/')); } /** diff --git a/lib/contact-photo-cache.ts b/lib/contact-photo-cache.ts index d3934d556..2e834f633 100644 --- a/lib/contact-photo-cache.ts +++ b/lib/contact-photo-cache.ts @@ -11,7 +11,10 @@ import { toInertBlob } from '@/lib/file-preview'; * avatars look photos up by sender, so one screen can ask for dozens at once. * Hence: * - one download per `${localAccountId}|${blobId}`, shared by every caller; - * - at most 2 at a time, and after a first burst a paced start rate; + * - at most half the server's maxConcurrentRequests at a time, and after a + * first burst a paced start rate; + * - avatars and print go first; downloads only an export asked for wait + * behind them and leave part of the burst to them; * - a queued download that no avatar on screen wants any more is dropped * before it starts; * - a failed download is tried once more a little later, since the server @@ -22,13 +25,30 @@ import { toInertBlob } from '@/lib/file-preview'; */ const MAX_ENTRIES = 300; -const MAX_CONCURRENT = 2; +// Half of the 4 requests at a time Stalwart takes from a user, for a server +// that doesn't say. +const DEFAULT_CONCURRENCY = 2; const BURST = 40; const REFILL_PER_SECOND = 6; +// Export downloads only take a token while this many are left, so a long +// export never spends the burst that avatars coming on screen need. +const BACKGROUND_RESERVE = BURST / 2; const RETRY_DELAY_MS = 1000; const FAILURE_TTL_MS = 2 * 60_000; -type Fetcher = () => Promise; +/** + * Resolves the photo's bytes, or null when there is nothing to ask right now + * (no client for the card's login): that is neither retried nor remembered + * as a failure. + */ +type Fetcher = () => Promise; + +export interface ContactPhotoDownloadOptions { + /** Downloads allowed at once for this photo's server; see contactPhotoConcurrency. */ + concurrency?: number; + /** Only an export wants it: it waits behind avatars and print. */ + background?: boolean; +} interface Download { fetcher: Fetcher; @@ -38,6 +58,8 @@ interface Download { forced: boolean; /** Keep the result for display; a download only for export is not cached. */ keep: boolean; + background: boolean; + concurrency: number; retried: boolean; waiters: Array<(blob: Blob | null) => void>; } @@ -46,15 +68,27 @@ const photos = new Map(); const failedUntil = new Map(); const downloads = new Map(); const queue: string[] = []; +const backgroundQueue: string[] = []; const listeners = new Set<() => void>(); let running = 0; let tokens = BURST; let refilledAt = 0; let pumpTimer: ReturnType | null = null; +let pumpAt = 0; // Bumped by clearContactPhotoCache, so a download that was running across a // clear is not stored into the next account's cache. let generation = 0; +/** + * Photo downloads to run at once for a server that takes + * `maxConcurrentRequests` requests from a user at a time (RFC 8620 core + * capability): half of them, so JMAP calls keep the other half. + */ +export function contactPhotoConcurrency(maxConcurrentRequests: number | undefined): number { + if (!maxConcurrentRequests || maxConcurrentRequests < 0) return DEFAULT_CONCURRENCY; + return Math.max(1, Math.floor(maxConcurrentRequests / 2)); +} + export function contactPhotoKey(localAccountId: string | undefined, blobId: string): string { return `${localAccountId ?? ''}|${blobId}`; } @@ -93,12 +127,29 @@ function recentlyFailed(key: string): boolean { return false; } -function enqueue(key: string, fetcher: Fetcher): Download { +function enqueue(key: string, fetcher: Fetcher, options: ContactPhotoDownloadOptions): Download { let download = downloads.get(key); if (!download) { - download = { fetcher, viewers: 0, forced: false, keep: false, retried: false, waiters: [] }; + download = { + fetcher, + viewers: 0, + forced: false, + keep: false, + background: !!options.background, + concurrency: options.concurrency ?? DEFAULT_CONCURRENCY, + retried: false, + waiters: [], + }; downloads.set(key, download); - queue.push(key); + (download.background ? backgroundQueue : queue).push(key); + } else if (download.background && !options.background) { + // An avatar or print now wants what only an export asked for. + download.background = false; + const at = backgroundQueue.indexOf(key); + if (at >= 0) { + backgroundQueue.splice(at, 1); + queue.push(key); + } } return download; } @@ -108,9 +159,13 @@ function enqueue(key: string, fetcher: Fetcher): Download { * the cache (peekContactPhoto) and subscribers are told. Call the returned * function when the avatar unmounts or scrolls away. */ -export function requestContactPhoto(key: string, fetcher: Fetcher): () => void { +export function requestContactPhoto( + key: string, + fetcher: Fetcher, + options: Pick = {}, +): () => void { if (touch(key) || recentlyFailed(key)) return () => {}; - const download = enqueue(key, fetcher); + const download = enqueue(key, fetcher, options); download.viewers++; download.keep = true; pump(); @@ -126,44 +181,61 @@ export function requestContactPhoto(key: string, fetcher: Fetcher): () => void { * The bytes of a photo for print, export or copying a card, from the cache * when it has them. Resolves null when the download fails. */ -export function loadContactPhotoBlob(key: string, fetcher: Fetcher): Promise { +export function loadContactPhotoBlob( + key: string, + fetcher: Fetcher, + options: ContactPhotoDownloadOptions = {}, +): Promise { const cached = touch(key); if (cached) return Promise.resolve(cached.blob); if (recentlyFailed(key)) return Promise.resolve(null); - const download = enqueue(key, fetcher); + const download = enqueue(key, fetcher, options); download.forced = true; const result = new Promise((resolve) => download.waiters.push(resolve)); pump(); return result; } -function takeToken(): boolean { +function refill(): void { const now = Date.now(); tokens = Math.min(BURST, tokens + (Math.max(0, now - refilledAt) / 1000) * REFILL_PER_SECOND); refilledAt = now; - if (tokens < 1) return false; - tokens -= 1; - return true; +} + +function schedulePump(delayMs: number): void { + const at = Date.now() + delayMs; + // A foreground download waits for the next token, not for the later one a + // background download was scheduled for. + if (pumpTimer && pumpAt <= at) return; + if (pumpTimer) clearTimeout(pumpTimer); + pumpAt = at; + pumpTimer = setTimeout(() => { + pumpTimer = null; + pump(); + }, delayMs); } function pump(): void { - while (running < MAX_CONCURRENT && queue.length > 0) { - const key = queue[0]; + for (;;) { + const from = queue.length > 0 ? queue : backgroundQueue.length > 0 ? backgroundQueue : null; + if (!from) return; + const key = from[0]; const download = downloads.get(key); if (!download || (!download.forced && download.viewers <= 0)) { // Scrolled away or unmounted before its turn came. - queue.shift(); + from.shift(); downloads.delete(key); continue; } - if (!takeToken()) { - pumpTimer ??= setTimeout(() => { - pumpTimer = null; - pump(); - }, Math.ceil(((1 - tokens) / REFILL_PER_SECOND) * 1000)); + if (running >= download.concurrency) return; + const reserve = from === backgroundQueue ? BACKGROUND_RESERVE : 0; + refill(); + if (tokens < 1 + reserve) { + schedulePump(Math.ceil(((1 + reserve - tokens) / REFILL_PER_SECOND) * 1000)); return; } - queue.shift(); + tokens -= 1; + from.shift(); start(key, download); } } @@ -171,25 +243,26 @@ function pump(): void { function start(key: string, download: Download): void { running++; const startedIn = generation; - let fetched: Promise; + let fetched: Promise; try { fetched = download.fetcher(); } catch (error) { fetched = Promise.reject(error); } fetched.then( - (blob) => finish(key, download, startedIn, blob), - () => finish(key, download, startedIn, null), + (blob) => finish(key, download, startedIn, blob, false), + () => finish(key, download, startedIn, null, true), ); } -function finish(key: string, download: Download, startedIn: number, blob: Blob | null): void { - running--; - if (!blob && !download.retried && startedIn === generation) { +function finish(key: string, download: Download, startedIn: number, blob: Blob | null, failed: boolean): void { + // A download from before a clear no longer counts against the new account. + if (startedIn === generation) running--; + if (failed && !download.retried && startedIn === generation) { download.retried = true; setTimeout(() => { if (downloads.get(key) !== download) return; - queue.push(key); + (download.background ? backgroundQueue : queue).push(key); pump(); }, RETRY_DELAY_MS); pump(); @@ -197,8 +270,8 @@ function finish(key: string, download: Download, startedIn: number, blob: Blob | } if (startedIn === generation) { downloads.delete(key); - if (!blob) failedUntil.set(key, Date.now() + FAILURE_TTL_MS); - else if (download.keep) remember(key, blob); + if (failed) failedUntil.set(key, Date.now() + FAILURE_TTL_MS); + else if (blob && download.keep) remember(key, blob); } for (const resolve of download.waiters) resolve(blob); pump(); @@ -227,8 +300,10 @@ export function clearContactPhotoCache(): void { for (const resolve of download.waiters) resolve(null); } queue.length = 0; + backgroundQueue.length = 0; downloads.clear(); // The limits are per account, and a clear means another account. + running = 0; tokens = BURST; refilledAt = 0; if (pumpTimer) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 899d31bc2..cda761d63 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -33,6 +33,14 @@ export interface KeywordDiscoveryResult { complete: boolean; } +/** What `getBlobsAsBase64` brought back. */ +export interface BlobBatch { + /** Contents as base64 by blobId, for every blob the server returned. */ + data: Map; + /** Blobs left unfetched because a request failed; they can still be downloaded one by one. */ + failed: string[]; +} + /** * Interface defining the public JMAP client contract. * @@ -88,6 +96,8 @@ export interface IJMAPClient { getMaxCallsInRequest(): number; getMaxObjectsInGet(): number; getMaxObjectsInSet(): number; + /** RFC 8620 maxConcurrentRequests: API calls and downloads the server runs at once for the user. */ + getMaxConcurrentRequests?(): number; getMaxDelayedSend(accountId?: string): number; hasDelayedSend(accountId?: string): boolean; getEventSourceUrl(): string | null; @@ -410,6 +420,11 @@ export interface IJMAPClient { fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise; fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise; + /** + * Many blobs at once through Blob/get (RFC 9404), for exports; null when + * the session doesn't advertise the blob capability. + */ + getBlobsAsBase64?(blobIds: string[], accountId?: string): Promise; downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise; // ── Identities ──────────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index fe14160d4..2ae761973 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,10 +1,10 @@ import { generateUUID } from '@/lib/utils'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; +import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; -import { batched, itemsPerRequest } from "./request-limits"; +import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; import { debug } from "@/lib/debug"; @@ -692,6 +692,16 @@ const FILE_NODE_QUERY_PAGE = 5000; /** Safety bound on how many FileNode ids one listing pages through. */ const FILE_NODE_MAX_IDS = 200_000; +/** RFC 9404; Blob/get needs it in `using`. */ +const BLOB_CAPABILITY = "urn:ietf:params:jmap:blob"; +/** + * Bytes of blob data one Blob/get in getBlobsAsBase64 asks for. They come + * back as base64 inside the JSON, a third larger. + */ +const BLOB_BATCH_BYTES = 4 * 1024 * 1024; +/** The largest contact photo Stalwart 1.0 takes by default (maxMediaSize). */ +const BLOB_LARGE_BYTES = 2 * 1024 * 1024; + function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean { if (total > 0) return (position + emailCount) < total; return emailCount === limit; @@ -5178,6 +5188,66 @@ export class JMAPClient implements IJMAPClient { return URL.createObjectURL(blob); } + /** + * The contents of many blobs as base64, through Blob/get (RFC 9404), for an + * export that needs a lot of them: one download per blob queues behind the + * server's per-user limits (Stalwart: 4 requests at a time, 1000 a minute), + * while one Blob/get returns many. + * + * More blobs than two of the largest photos would fill are first asked for + * their sizes, then fetched in requests of about BLOB_BATCH_BYTES, so a + * response stays bounded however large the photos are. + * + * Null when the session doesn't advertise the blob capability, which then + * must stay out of `using`. Blobs the server doesn't find are left out; once + * a request fails, the blobs not fetched yet are listed in `failed`. + */ + async getBlobsAsBase64(blobIds: string[], accountId?: string): Promise { + if (!this.hasCapability(BLOB_CAPABILITY)) return null; + const account = accountId || this.accountId; + const ids = [...new Set(blobIds)]; + const data = new Map(); + const blobGet = async (chunk: string[], property: "size" | "data:asBase64") => { + const response = await this.request([ + ["Blob/get", { accountId: account, ids: chunk, properties: [property] }, "0"], + ], ["urn:ietf:params:jmap:core", BLOB_CAPABILITY]); + const [name, result] = response.methodResponses?.[0] ?? []; + if (name !== "Blob/get") { + throw jmapError(`Blob/get failed: ${result?.description || result?.type || "unknown"}`, result); + } + return (result.list ?? []) as Array>; + }; + + let chunks: string[][] = ids.length > 0 ? [ids] : []; + if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) { + const sizes = new Map(); + try { + for (const chunk of batched(ids, this.getMaxObjectsInGet())) { + for (const blob of await blobGet(chunk, "size")) { + if (typeof blob.id === "string") sizes.set(blob.id, typeof blob.size === "number" ? blob.size : undefined); + } + } + } catch (error) { + debug.warn("jmap", "Blob/get of sizes failed:", error); + return { data, failed: ids }; + } + chunks = batchedBySize(ids.filter((id) => sizes.has(id)), (id) => sizes.get(id), BLOB_BATCH_BYTES, this.getMaxObjectsInGet()); + } + + for (let i = 0; i < chunks.length; i++) { + try { + for (const blob of await blobGet(chunks[i], "data:asBase64")) { + const base64 = blob["data:asBase64"]; + if (typeof blob.id === "string" && typeof base64 === "string") data.set(blob.id, base64); + } + } catch (error) { + debug.warn("jmap", "Blob/get failed:", error); + return { data, failed: chunks.slice(i).flat() }; + } + } + return { data, failed: [] }; + } + getCapabilities(): Record { return this.capabilities; } @@ -5253,6 +5323,11 @@ export class JMAPClient implements IJMAPClient { return coreCapability?.maxObjectsInSet || 500; } + getMaxConcurrentRequests(): number { + const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxConcurrentRequests?: number } | undefined; + return coreCapability?.maxConcurrentRequests || 4; + } + getMaxDelayedSend(accountId?: string): number { const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend; if (typeof maxDelayedSend !== 'number' || maxDelayedSend <= 0) return 0; diff --git a/lib/jmap/request-limits.ts b/lib/jmap/request-limits.ts index 92fd1d612..272160811 100644 --- a/lib/jmap/request-limits.ts +++ b/lib/jmap/request-limits.ts @@ -20,6 +20,30 @@ export function batched(items: T[], size: number): T[][] { return result; } +/** + * Split `items` into consecutive batches whose sizes add up to at most + * `budget`, and of at most `maxCount` entries. An item larger than the budget + * gets a batch of its own; an item without a size counts as the whole budget. + */ +export function batchedBySize(items: T[], sizeOf: (item: T) => number | undefined, budget: number, maxCount: number): T[][] { + const limit = Math.max(1, Math.floor(maxCount)); + const result: T[][] = []; + let batch: T[] = []; + let total = 0; + for (const item of items) { + const size = sizeOf(item) ?? budget; + if (batch.length > 0 && (total + size > budget || batch.length >= limit)) { + result.push(batch); + batch = []; + total = 0; + } + batch.push(item); + total += size; + } + if (batch.length > 0) result.push(batch); + return result; +} + /** How many items fit in one request when each item costs `callsPerItem` method calls. */ export function itemsPerRequest(maxCalls: number, callsPerItem: number): number { return Math.max(1, Math.floor(maxCalls / callsPerItem)); diff --git a/locales/ar/common.json b/locales/ar/common.json index 25a5c3964..2b5b05d04 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "تصدير جهات الاتصال", - "success": "{count, plural, one {تم تصدير جهة اتصال واحدة} other {تم تصدير # جهة اتصال}}" + "success": "{count, plural, one {تم تصدير جهة اتصال واحدة} other {تم تصدير # جهة اتصال}}", + "exporting": "جارٍ التصدير..." }, "bulk": { "selected": "{count, plural, one {تم تحديد واحدة} other {تم تحديد #}}", diff --git a/locales/ca/common.json b/locales/ca/common.json index 38dabfb69..5e8a5b51c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Exporta contactes", - "success": "{count, plural, one {1 contacte exportat} other {# contactes exportats}}" + "success": "{count, plural, one {1 contacte exportat} other {# contactes exportats}}", + "exporting": "Exportant..." }, "bulk": { "selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}", diff --git a/locales/cs/common.json b/locales/cs/common.json index 85fe64886..eed2ead84 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Exportovat kontakty", - "success": "{count, plural, one {Exportován 1 kontakt} few {Exportovány # kontakty} other {Exportováno # kontaktů}}" + "success": "{count, plural, one {Exportován 1 kontakt} few {Exportovány # kontakty} other {Exportováno # kontaktů}}", + "exporting": "Exportování..." }, "bulk": { "selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}", diff --git a/locales/da/common.json b/locales/da/common.json index 53a58c991..36ac2519c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Eksportér kontakter", - "success": "{count, plural, one {1 kontakt eksporteret} other {# kontakter eksporteret}}" + "success": "{count, plural, one {1 kontakt eksporteret} other {# kontakter eksporteret}}", + "exporting": "Eksporterer..." }, "bulk": { "selected": "{count, plural, one {1 valgt} other {# valgte}}", diff --git a/locales/de/common.json b/locales/de/common.json index df2d42ec5..e88c5aba5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Kontakte exportieren", - "success": "{count, plural, one {1 Kontakt exportiert} other {# Kontakte exportiert}}" + "success": "{count, plural, one {1 Kontakt exportiert} other {# Kontakte exportiert}}", + "exporting": "Wird exportiert..." }, "bulk": { "selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}", diff --git a/locales/en/common.json b/locales/en/common.json index 52eefc50b..919a4c8ac 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Export Contacts", - "success": "{count, plural, one {1 contact exported} other {# contacts exported}}" + "success": "{count, plural, one {1 contact exported} other {# contacts exported}}", + "exporting": "Exporting..." }, "bulk": { "selected": "{count, plural, one {1 selected} other {# selected}}", diff --git a/locales/es/common.json b/locales/es/common.json index 705415c35..ddad17d74 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Exportar contactos", - "success": "{count, plural, one {1 contacto exportado} other {# contactos exportados}}" + "success": "{count, plural, one {1 contacto exportado} other {# contactos exportados}}", + "exporting": "Exportando..." }, "bulk": { "selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}", diff --git a/locales/fa/common.json b/locales/fa/common.json index f08eb51de..b77c111b1 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "خروجی مخاطبین", - "success": "{count, plural, one {۱ مخاطب خروجی گرفته شد} other {# مخاطب خروجی گرفته شد}}" + "success": "{count, plural, one {۱ مخاطب خروجی گرفته شد} other {# مخاطب خروجی گرفته شد}}", + "exporting": "در حال خروجی گرفتن..." }, "bulk": { "selected": "{count, plural, one {۱ انتخاب شده} other {# انتخاب شده}}", diff --git a/locales/fr/common.json b/locales/fr/common.json index 2d68d7204..ae297215b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Exporter les contacts", - "success": "{count, plural, one {1 contact exporté} other {# contacts exportés}}" + "success": "{count, plural, one {1 contact exporté} other {# contacts exportés}}", + "exporting": "Exportation..." }, "bulk": { "selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}", diff --git a/locales/he/common.json b/locales/he/common.json index ab69ac736..6f2d4125a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2633,7 +2633,8 @@ }, "export": { "title": "ייצוא אנשי קשר", - "success": "{count, plural, one {יוצא איש קשר אחד} other {יוצאו # אנשי קשר}}" + "success": "{count, plural, one {יוצא איש קשר אחד} other {יוצאו # אנשי קשר}}", + "exporting": "מייצא..." }, "bulk": { "selected": "{count, plural, one {נבחר אחד} other {נבחרו #}}", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2cc40554d..a193c3686 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Névjegyek exportálása", - "success": "{count, plural, one {1 névjegy exportálva} other {# névjegy exportálva}}" + "success": "{count, plural, one {1 névjegy exportálva} other {# névjegy exportálva}}", + "exporting": "Exportálás..." }, "bulk": { "selected": "{count, plural, one {1 kijelölve} other {# kijelölve}}", diff --git a/locales/it/common.json b/locales/it/common.json index 4c637500c..9558cd7e7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Esporta contatti", - "success": "{count, plural, one {1 contatto esportato} other {# contatti esportati}}" + "success": "{count, plural, one {1 contatto esportato} other {# contatti esportati}}", + "exporting": "Esportazione..." }, "bulk": { "selected": "{count, plural, one {1 selezionato} other {# selezionati}}", diff --git a/locales/ja/common.json b/locales/ja/common.json index 2930237b0..c52edd8c7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "連絡先をエクスポート", - "success": "{count, plural, other {#件の連絡先をエクスポートしました}}" + "success": "{count, plural, other {#件の連絡先をエクスポートしました}}", + "exporting": "エクスポート中..." }, "bulk": { "selected": "{count, plural, other {#件選択中}}", diff --git a/locales/ko/common.json b/locales/ko/common.json index be73a9872..fec673724 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "연락처 내보내기", - "success": "{count}개의 연락처를 성공적으로 내보냈어요" + "success": "{count}개의 연락처를 성공적으로 내보냈어요", + "exporting": "내보내는 중..." }, "bulk": { "selected": "{count}개 선택됨", diff --git a/locales/lv/common.json b/locales/lv/common.json index a6b26715b..879d5bf13 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2719,7 +2719,8 @@ }, "export": { "title": "Kontaktu eksports", - "success": "Eksportēts {count, plural, one {1 kontakts} other {# kontakti}}" + "success": "Eksportēts {count, plural, one {1 kontakts} other {# kontakti}}", + "exporting": "Eksportē..." }, "bulk": { "selected": "Atlasīts {count, plural, one {1 kontakts} other {# kontakti}}", diff --git a/locales/mn/common.json b/locales/mn/common.json index dd8fcfc05..b773d8c34 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Харилцагчдыг экспортлох", - "success": "{count, plural, one {1 контакт экспортлогдлоо} other {# контакт экспортлогдлоо}}" + "success": "{count, plural, one {1 контакт экспортлогдлоо} other {# контакт экспортлогдлоо}}", + "exporting": "Экспорт хийж байна..." }, "bulk": { "selected": "{count, plural, one {1 сонгогдсон} other {# сонгогдсон}}", diff --git a/locales/nb/common.json b/locales/nb/common.json index 7044ae101..3af43229b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Eksporter kontakter", - "success": "{count, plural, one {1 kontakt eksportert} other {# kontakter eksportert}}" + "success": "{count, plural, one {1 kontakt eksportert} other {# kontakter eksportert}}", + "exporting": "Eksporterer..." }, "bulk": { "selected": "{count, plural, one {1 valgt} other {# valgte}}", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4f596d618..378187d77 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Contacten exporteren", - "success": "{count, plural, one {1 contact geëxporteerd} other {# contacten geëxporteerd}}" + "success": "{count, plural, one {1 contact geëxporteerd} other {# contacten geëxporteerd}}", + "exporting": "Exporteren..." }, "bulk": { "selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}", diff --git a/locales/pl/common.json b/locales/pl/common.json index 29eed7c2b..1b8675528 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Eksportuj kontakty", - "success": "{count, plural, one {Wyeksportowano 1 kontakt} other {Wyeksportowano # kontaktów}}" + "success": "{count, plural, one {Wyeksportowano 1 kontakt} other {Wyeksportowano # kontaktów}}", + "exporting": "Eksportowanie..." }, "bulk": { "selected": "{count, plural, one {1 zaznaczony} other {# zaznaczonych}}", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5643626e3..deb0c10e5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Exportar contatos", - "success": "{count, plural, one {1 contato exportado} other {# contatos exportados}}" + "success": "{count, plural, one {1 contato exportado} other {# contatos exportados}}", + "exporting": "Exportando..." }, "bulk": { "selected": "{count, plural, one {1 selecionado} other {# selecionados}}", diff --git a/locales/ro/common.json b/locales/ro/common.json index 092e3ed62..4dc8b4363 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Exportați contactele", - "success": "{count, plural, one {1 contact exportat} few {# contacte exportate} other {# de contacte exportate}}" + "success": "{count, plural, one {1 contact exportat} few {# contacte exportate} other {# de contacte exportate}}", + "exporting": "Se exportă..." }, "bulk": { "selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}", diff --git a/locales/ru/common.json b/locales/ru/common.json index f4e5df702..643414f00 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Экспорт контактов", - "success": "{count, plural, one {1 контакт экспортирован} other {# контактов экспортировано}}" + "success": "{count, plural, one {1 контакт экспортирован} other {# контактов экспортировано}}", + "exporting": "Экспортирование..." }, "bulk": { "selected": "{count, plural, one {1 выбран} other {# выбрано}}", diff --git a/locales/sk/common.json b/locales/sk/common.json index c7416e7f7..fc946b889 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "Exportovať kontakty", - "success": "{count, plural, one {Exportovaný 1 kontakt} other {Exportovaných # kontaktov}}" + "success": "{count, plural, one {Exportovaný 1 kontakt} other {Exportovaných # kontaktov}}", + "exporting": "Exportovanie..." }, "bulk": { "selected": "{count, plural, one {1 vybraný} other {# vybraných}}", diff --git a/locales/tr/common.json b/locales/tr/common.json index 36c45d35e..02bfc4efe 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Kişileri Dışa Aktar", - "success": "{count, plural, one {1 kişi dışa aktarıldı} other {# kişi dışa aktarıldı}}" + "success": "{count, plural, one {1 kişi dışa aktarıldı} other {# kişi dışa aktarıldı}}", + "exporting": "Dışa aktarılıyor..." }, "bulk": { "selected": "{count, plural, one {1 seçildi} other {# seçildi}}", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3d5de4209..8045cc2f4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "Експортувати контакти", - "success": "{count, plural, one {1 контакт експортовано} few {# контакти експортовано} many {# контактів експортовано} other {# контактів експортовано}}" + "success": "{count, plural, one {1 контакт експортовано} few {# контакти експортовано} many {# контактів експортовано} other {# контактів експортовано}}", + "exporting": "Експорт..." }, "bulk": { "selected": "{count, plural, one {1 вибрано} few {# вибрано} many {# вибрано} other {# вибрано}}", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 820853d73..ad7b8ea29 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2724,7 +2724,8 @@ }, "export": { "title": "匯出聯絡人", - "success": "{count, plural, one {已匯出 1 位聯絡人} other {已匯出 # 位聯絡人}}" + "success": "{count, plural, one {已匯出 1 位聯絡人} other {已匯出 # 位聯絡人}}", + "exporting": "正在匯出…" }, "bulk": { "selected": "{count, plural, one {已選取 1 項} other {已選取 # 項}}", diff --git a/locales/zh/common.json b/locales/zh/common.json index 9e25f42eb..d4b3189f8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2723,7 +2723,8 @@ }, "export": { "title": "导出联系人", - "success": "{count, plural, one {已导出 1 位联系人} other {已导出 # 位联系人}}" + "success": "{count, plural, one {已导出 1 位联系人} other {已导出 # 位联系人}}", + "exporting": "导出中..." }, "bulk": { "selected": "{count, plural, one {已选择 1 项} other {已选择 # 项}}", diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index dade0ebbc..2611df348 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -875,6 +875,31 @@ describe('contact-store', () => { }); }); + describe('fetchContactFromServer', () => { + // The contact form rebuilds `media` on this when the photo changed: the + // list's copy may carry a blobId Stalwart 1.0 no longer resolves. + it('reads a shared card from its account by its server id', async () => { + useContactStore.setState({ + contacts: [makeContact({ id: 'shared-1:c-9', originalId: 'c-9', isShared: true, accountId: 'shared-1' })], + }); + const current = makeContact({ id: 'c-9', media: { logo: { kind: 'logo', blobId: 'dbcLogoNew' } } }); + const client = { getContact: vi.fn(async () => current) }; + + const card = await useContactStore.getState().fetchContactFromServer(client as unknown as IJMAPClient, 'shared-1:c-9'); + + expect(client.getContact).toHaveBeenCalledWith('c-9', 'shared-1'); + expect(card).toBe(current); + }); + + it('reads an own card from the default account', async () => { + useContactStore.setState({ contacts: [makeContact({ id: 'c-1' })] }); + const client = { getContact: vi.fn(async () => null) }; + + expect(await useContactStore.getState().fetchContactFromServer(client as unknown as IJMAPClient, 'c-1')).toBeNull(); + expect(client.getContact).toHaveBeenCalledWith('c-1', undefined); + }); + }); + describe('moving a contact to another account', () => { beforeEach(() => { clearContactPhotoCache(); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 862a602d3..00d7fa094 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -404,6 +404,8 @@ interface ContactStore { fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[]) => Promise; createContact: (client: IJMAPClient, contact: Partial, options?: ContactSaveOptions) => Promise; updateContact: (client: IJMAPClient, id: string, updates: Partial, options?: ContactSaveOptions) => Promise; + /** The card as its server has it now, or null when it can't be read. */ + fetchContactFromServer: (client: IJMAPClient, id: string) => Promise; deleteContact: (client: IJMAPClient, id: string) => Promise; addLocalContact: (contact: ContactCard) => void; @@ -677,6 +679,13 @@ export const useContactStore = create()( } }, + fetchContactFromServer: async (client, id) => { + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); + const accountId = contact?.isShared ? contact.accountId : undefined; + return resolveAccountClient(client, contact?.localAccountId).getContact(originalId, accountId); + }, + deleteContact: async (client, id) => { set({ error: null }); try {