mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: export contact photos in batches on Stalwart 1.0
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.
- Export fetches the blob media of all cards with Blob/get
(`data:asBase64`), asking for the sizes first and packing the requests
to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
when the session advertises it; without it, or when a request is
refused, the rest is downloaded one by one behind avatars and print,
which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
the detail menu show that an export is running and ignore clicks until
it is done.
- When the photo changes, the form rebuilds `media` on the card as the
server has it now. A logo or sound blobId from the list's older copy
made 1.0 refuse the whole update ("The following blobIds do not exist
or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
one it replaces, so it stays the photo shown on cards with several.
Removing the photo removes every photo the card had instead of only the
shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
in media, not only `media` itself.
This commit is contained in:
@@ -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<BlobBatch>((resolve) => { deliver = resolve; }),
|
||||
} as never,
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
let running!: Promise<boolean>;
|
||||
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' } } }],
|
||||
|
||||
@@ -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<BlobBatch> => ({
|
||||
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<BlobBatch>((resolve) => { deliver = resolve; }));
|
||||
useAuthStore.setState({ client: { fetchBlob, getContactsAccountId, getBlobsAsBase64 } as never });
|
||||
const { result } = renderHook(() => useContactExportBusy());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
let first!: Promise<boolean>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Record<string, ContactMedia>>;
|
||||
|
||||
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(
|
||||
<ContactForm contact={withMedia({ main, other, logo: logo1_0 })} onSave={onSave} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
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<string, ContactMedia>;
|
||||
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(<ContactForm contact={withMedia({ main, other, logo: logo1_0 })} onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
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<Pick<ContactCard, 'media'> | null>) {
|
||||
stubImagePipeline();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const { container } = render(
|
||||
<ContactForm
|
||||
contact={withMedia({ photo: photo1_0, logo: staleLogo })}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
loadCurrentMedia={loadCurrentMedia}
|
||||
/>,
|
||||
);
|
||||
await pickPhoto(container);
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
|
||||
return onSave.mock.calls[0][0].media as Record<string, ContactMedia>;
|
||||
}
|
||||
|
||||
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(
|
||||
<ContactForm
|
||||
contact={withMedia({ photo: photo1_0, logo: staleLogo })}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
loadCurrentMedia={loadCurrentMedia}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<null>);
|
||||
|
||||
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(
|
||||
<ContactForm
|
||||
contact={withMedia({ photo: photo1_0, logo: staleLogo })}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
loadCurrentMedia={loadCurrentMedia}
|
||||
/>,
|
||||
);
|
||||
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 () => {
|
||||
|
||||
@@ -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<ContactCard> & { 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<BlobBatch>((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(<ContactList {...defaultProps} onBulkExport={onBulkExport} selectedContactIds={new Set(['1'])} />);
|
||||
let running!: Promise<boolean>;
|
||||
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' }],
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -188,6 +191,7 @@ export function ContactContextMenu({
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
onClick={handle(handleExport)}
|
||||
disabled={exporting}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Printer}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { exportContact, useContactExportBusy } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -22,6 +22,7 @@ type MoreItem =
|
||||
label: string;
|
||||
onClick: () => 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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<ContactCard> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const name = getContactDisplayName(contact) || "contact";
|
||||
return runExport([contact], `${sanitizeFilename(name)}.vcf`);
|
||||
}
|
||||
|
||||
export function exportContacts(contacts: ContactCard[]): Promise<boolean> {
|
||||
return runExport(contacts, `contacts-${new Date().toISOString().slice(0, 10)}.vcf`);
|
||||
}
|
||||
|
||||
function downloadVcf(content: string, filename: string) {
|
||||
|
||||
@@ -58,6 +58,12 @@ interface ContactFormProps {
|
||||
/** `options.photoKey` names the `media` entry holding a newly picked photo. */
|
||||
onSave: (data: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
|
||||
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<Pick<ContactCard, "media"> | 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, ContactMedia>): 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<string, ContactMedia>,
|
||||
known: Record<string, ContactMedia>,
|
||||
shownKey: string | undefined,
|
||||
edit: Exclude<PhotoEdit, { kind: "unchanged" }>,
|
||||
): { media: Record<string, ContactMedia> | 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<string, ContactMedia> = {};
|
||||
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<string, ContactMedia> | null | undefined;
|
||||
let photoKey: string | undefined;
|
||||
if (photoEdit.kind === "new" || (photoEdit.kind === "removed" && initialPhotoKey)) {
|
||||
const others: Record<string, ContactMedia> = { ...(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<string, ContactMedia> } : {}),
|
||||
...(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<string, ContactMedia>;
|
||||
photoKey = edited.photoKey;
|
||||
}
|
||||
await (photoKey ? onSave(data, { photoKey }) : onSave(data));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("save_failed"));
|
||||
|
||||
@@ -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<ListFilters>(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({
|
||||
<Users className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.add_to_group")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
||||
<Download className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.export")}
|
||||
<Button variant="ghost" size="sm" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} className="h-7 text-xs">
|
||||
{exporting
|
||||
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
|
||||
: <Download className="w-3.5 h-3.5 me-1" />}
|
||||
{exporting ? t("export.exporting") : t("bulk.export")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -82,6 +82,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
fetchContactFromServer,
|
||||
deleteContact,
|
||||
addLocalContact,
|
||||
updateLocalContact,
|
||||
@@ -728,8 +729,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
|
||||
const handleBulkExport = async () => {
|
||||
const toExport = contacts.filter(c => selectedContactIds.has(c.id));
|
||||
if (toExport.length > 0) {
|
||||
await exportContacts(toExport);
|
||||
if (toExport.length > 0 && await exportContacts(toExport)) {
|
||||
toast.success(t("export.success", { count: toExport.length }));
|
||||
clearSelection();
|
||||
}
|
||||
@@ -766,6 +766,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
allKeywords={allKeywords}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
loadCurrentMedia={supportsSync && client ? () => fetchContactFromServer(client, selectedContact.id) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, Download } from "@/components/icons";
|
||||
import { Upload, Download, Loader2 } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { exportContacts, useContactExportBusy } from "@/components/contacts/contact-export";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -25,6 +25,7 @@ export function ContactsSettings() {
|
||||
const sortContactsByLastName = useSettingsStore((s) => s.sortContactsByLastName);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const exporting = useContactExportBusy();
|
||||
|
||||
const individuals = contacts.filter(c => c.kind !== "group");
|
||||
|
||||
@@ -36,8 +37,7 @@ export function ContactsSettings() {
|
||||
}, [supportsSync, client, importContacts]);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (individuals.length > 0) {
|
||||
await exportContacts(individuals);
|
||||
if (individuals.length > 0 && await exportContacts(individuals)) {
|
||||
toast.success(t("export.success", { count: individuals.length }));
|
||||
}
|
||||
};
|
||||
@@ -97,10 +97,13 @@ export function ContactsSettings() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={individuals.length === 0}
|
||||
disabled={individuals.length === 0 || exporting}
|
||||
aria-busy={exporting}
|
||||
>
|
||||
<Download className="w-4 h-4 me-2" />
|
||||
{t("export.title")}
|
||||
{exporting
|
||||
? <Loader2 className="w-4 h-4 me-2 animate-spin" />
|
||||
: <Download className="w-4 h-4 me-2" />}
|
||||
{exporting ? t("export.exporting") : t("export.title")}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -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(<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcGone' }} />);
|
||||
|
||||
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<Blob>((resolve) => pending.push(() => resolve(jpeg()))));
|
||||
useAuthStore.setState({ client: { fetchBlob, getMaxConcurrentRequests: () => limit } as never });
|
||||
|
||||
render(<>{[0, 1, 2, 3, 4, 5].map((i) => <Avatar key={i} name="Ann" contactPhoto={{ blobId: `dbc${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(<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcSvg', mediaType: 'image/svg+xml' }} />);
|
||||
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(
|
||||
<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcAnn', mediaType: 'image/jpeg', localAccountId: 'other@host' }} />,
|
||||
);
|
||||
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(<Avatar name="Ann" contactPhoto={{ blobId: 'dbcAnn' }} disableImages />);
|
||||
expect(fetchBlob).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Element, (visible: boolean) => 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<Element | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function connectedClient(options?: Parameters<typeof makeSession>[0]): Promise<JMAPClient> {
|
||||
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<string, number>, 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'] });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+107
-16
@@ -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<string, ContactMedia> | 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<ContactMedia, 'kind' | 'mediaType'> & { blobId: string },
|
||||
source: ContactMediaSource,
|
||||
options: { background?: boolean } = {},
|
||||
): Promise<Blob | null> {
|
||||
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<string, ContactMedia>,
|
||||
uriOf: (entry: BlobMedia) => Promise<string | null>,
|
||||
onFailure: 'drop' | 'keep',
|
||||
): Promise<Record<string, ContactMedia>> {
|
||||
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<string | null> {
|
||||
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<Record<string, ContactMedia> | 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<ContactCard[]> {
|
||||
const sourceKey = (contact: ContactCard) => `${contact.localAccountId ?? ''}|${contact.accountId ?? ''}`;
|
||||
const groups = new Map<string, { source: ContactMediaSource; entries: Map<string, BlobMedia> }>();
|
||||
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<string, string>();
|
||||
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/'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+107
-32
@@ -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<Blob>;
|
||||
/**
|
||||
* 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<Blob | null>;
|
||||
|
||||
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<string, { blob: Blob; url: string }>();
|
||||
const failedUntil = new Map<string, number>();
|
||||
const downloads = new Map<string, Download>();
|
||||
const queue: string[] = [];
|
||||
const backgroundQueue: string[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
let running = 0;
|
||||
let tokens = BURST;
|
||||
let refilledAt = 0;
|
||||
let pumpTimer: ReturnType<typeof setTimeout> | 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<ContactPhotoDownloadOptions, 'concurrency'> = {},
|
||||
): () => 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<Blob | null> {
|
||||
export function loadContactPhotoBlob(
|
||||
key: string,
|
||||
fetcher: Fetcher,
|
||||
options: ContactPhotoDownloadOptions = {},
|
||||
): Promise<Blob | null> {
|
||||
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<Blob | null>((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<Blob>;
|
||||
let fetched: Promise<Blob | null>;
|
||||
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) {
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<Blob>;
|
||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
|
||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise<ArrayBuffer>;
|
||||
/**
|
||||
* 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<BlobBatch | null>;
|
||||
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Identities ────────────────────────────────────────────────
|
||||
|
||||
+77
-2
@@ -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<BlobBatch | null> {
|
||||
if (!this.hasCapability(BLOB_CAPABILITY)) return null;
|
||||
const account = accountId || this.accountId;
|
||||
const ids = [...new Set(blobIds)];
|
||||
const data = new Map<string, string>();
|
||||
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<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
let chunks: string[][] = ids.length > 0 ? [ids] : [];
|
||||
if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) {
|
||||
const sizes = new Map<string, number | undefined>();
|
||||
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<string, unknown> {
|
||||
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;
|
||||
|
||||
@@ -20,6 +20,30 @@ export function batched<T>(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<T>(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));
|
||||
|
||||
@@ -2724,7 +2724,8 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "تصدير جهات الاتصال",
|
||||
"success": "{count, plural, one {تم تصدير جهة اتصال واحدة} other {تم تصدير # جهة اتصال}}"
|
||||
"success": "{count, plural, one {تم تصدير جهة اتصال واحدة} other {تم تصدير # جهة اتصال}}",
|
||||
"exporting": "جارٍ التصدير..."
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, one {تم تحديد واحدة} other {تم تحديد #}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -2724,7 +2724,8 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "خروجی مخاطبین",
|
||||
"success": "{count, plural, one {۱ مخاطب خروجی گرفته شد} other {# مخاطب خروجی گرفته شد}}"
|
||||
"success": "{count, plural, one {۱ مخاطب خروجی گرفته شد} other {# مخاطب خروجی گرفته شد}}",
|
||||
"exporting": "در حال خروجی گرفتن..."
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, one {۱ انتخاب شده} other {# انتخاب شده}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -2633,7 +2633,8 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "ייצוא אנשי קשר",
|
||||
"success": "{count, plural, one {יוצא איש קשר אחד} other {יוצאו # אנשי קשר}}"
|
||||
"success": "{count, plural, one {יוצא איש קשר אחד} other {יוצאו # אנשי קשר}}",
|
||||
"exporting": "מייצא..."
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, one {נבחר אחד} other {נבחרו #}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -2723,7 +2723,8 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "連絡先をエクスポート",
|
||||
"success": "{count, plural, other {#件の連絡先をエクスポートしました}}"
|
||||
"success": "{count, plural, other {#件の連絡先をエクスポートしました}}",
|
||||
"exporting": "エクスポート中..."
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, other {#件選択中}}",
|
||||
|
||||
@@ -2723,7 +2723,8 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "연락처 내보내기",
|
||||
"success": "{count}개의 연락처를 성공적으로 내보냈어요"
|
||||
"success": "{count}개의 연락처를 성공적으로 내보냈어요",
|
||||
"exporting": "내보내는 중..."
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count}개 선택됨",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 {# сонгогдсон}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 {# выбрано}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 {# вибрано}}",
|
||||
|
||||
@@ -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 {已選取 # 項}}",
|
||||
|
||||
@@ -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 {已选择 # 项}}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -404,6 +404,8 @@ interface ContactStore {
|
||||
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[]) => Promise<void>;
|
||||
createContact: (client: IJMAPClient, contact: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
|
||||
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
|
||||
/** The card as its server has it now, or null when it can't be read. */
|
||||
fetchContactFromServer: (client: IJMAPClient, id: string) => Promise<ContactCard | null>;
|
||||
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
|
||||
|
||||
addLocalContact: (contact: ContactCard) => void;
|
||||
@@ -677,6 +679,13 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user