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 {