From f52ef7db7ef250b29ff4f7e54d7883447931e9ed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:32:13 +0200 Subject: [PATCH] fix: show and keep contact photos in the app on Stalwart 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stalwart 1.0 returns every photo, logo and sound in a contact card as a blobId without a uri, including photos saved as data: URIs. The app read only `uri`, so sender avatars, the contact list, the detail screen, the form preview and vCard export showed nothing. Worse, the form rebuilt `media` from the card on every save and sent it without the photo, or as `null`, so editing a phone number deleted the photo on the server. - `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and `uri` is optional. The photo shown is the photo entry with the lowest `pref`, as a URI (0.16) or a blob (1.0). - Blob photos load from the download URL with the Authorization header, like file thumbnails. At most half of the server's `maxConcurrentRequests` load at once, a failed one is tried once more and then left alone for two minutes, and one that loaded skips the queue from then on. - The form leaves `media` out of the save unless the photo changed. A changed photo is built on the card as the server has it now, so a stale blobId can't make 1.0 refuse the update, and a new photo keeps the key, `pref`, `label` and `contexts` of the one it replaces. - A new photo is uploaded into the user's own account and sent as a blobId where the blob capability lists ContactCard (1.0). Otherwise it goes as a data: URI, and a refusal like 0.16's "blobIds in media is not supported." retries once with the data: URI and is remembered for the account. - vCard export fetches blob photos with batched Blob/get requests and writes them inline. It also writes photos whose data: URI names no media type, which it dropped before. Duplicate and moving a card to another account send the bytes instead of the source's blobIds. - An update never clears `id`, `uid` or `addressBookIds`, and vCard export writes MEMBER values that are already URIs as they are instead of `urn:uuid:urn:uuid:…`. The form's state and patch building moved to `src/lib/contact-form.ts` so the save path can be tested. --- src/api/__tests__/contacts.test.ts | 73 +++ src/api/blob.ts | 108 ++++ src/api/contacts.ts | 47 +- src/api/jmap-result.ts | 25 + src/api/types.ts | 11 +- src/components/SenderAvatar.tsx | 36 +- src/components/contacts/ContactListRow.tsx | 28 +- src/components/contacts/ContactPhoto.tsx | 29 + src/components/settings/ContactsSettings.tsx | 4 +- src/lib/__tests__/avatar-utils.test.ts | 50 +- src/lib/__tests__/contact-form.test.ts | 88 +++ src/lib/__tests__/contact-media.test.ts | 443 +++++++++++++++ .../__tests__/contact-photo-loader.test.ts | 155 ++++++ src/lib/__tests__/contact-utils.test.ts | 45 ++ src/lib/__tests__/vcard.test.ts | 66 +++ src/lib/avatar-utils.ts | 18 +- src/lib/contact-form.ts | 441 +++++++++++++++ src/lib/contact-media.ts | 321 +++++++++++ src/lib/contact-photo-loader.ts | 214 ++++++++ src/lib/contact-utils.ts | 53 +- src/lib/data-uri.ts | 46 ++ src/lib/use-contact-photo.ts | 75 +++ src/lib/vcard.ts | 21 +- src/screens/ContactDetailScreen.tsx | 26 +- src/screens/ContactFormScreen.tsx | 514 ++---------------- src/screens/ContactsScreen.tsx | 16 +- src/stores/__tests__/contacts-store.test.ts | 99 ++++ src/stores/contacts-store.ts | 49 +- 28 files changed, 2557 insertions(+), 544 deletions(-) create mode 100644 src/components/contacts/ContactPhoto.tsx create mode 100644 src/lib/__tests__/contact-form.test.ts create mode 100644 src/lib/__tests__/contact-media.test.ts create mode 100644 src/lib/__tests__/contact-photo-loader.test.ts create mode 100644 src/lib/contact-form.ts create mode 100644 src/lib/contact-media.ts create mode 100644 src/lib/contact-photo-loader.ts create mode 100644 src/lib/data-uri.ts create mode 100644 src/lib/use-contact-photo.ts diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index 92e50d35..35ceacdc 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -16,7 +16,9 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; +import { JMAPSetError } from '../jmap-result'; import { + getContactMedia, getAddressBooks, getAllAddressBooks, getContactsAccountId, @@ -280,6 +282,77 @@ describe('contacts operations', () => { await updateContact('c1', { kind: 'org' }, 'acc-team'); expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-team'); }); + + it.each(['1.0', '0.16'] as const)('leaves a cleared id, uid and addressBookIds out of the update on Stalwart %s', async (version) => { + // What ContactCard/set answers to those keys set to null (1.0 contact/set.rs, + // 0.16.23 set.rs): refused, or on 0.16 the card silently loses its UID. + mockRequest.mockImplementation(async (calls: Array<[string, { update: Record> }, string]>) => { + const update = calls[0][1].update.c1; + const refuse = (property: string, description: string) => + ({ notUpdated: { c1: { type: 'invalidProperties', properties: [property], description } } }); + let answer: Record = { updated: { c1: null } }; + if ('id' in update) answer = refuse('id', 'The id property is immutable.'); + else if (update.addressBookIds === null) answer = refuse('addressBookIds', 'Invalid patch operation for addressBookIds.'); + else if (update.uid === null && version === '1.0') answer = refuse('uid', 'You cannot change the UID of a contact.'); + return { methodResponses: [['ContactCard/set', answer, '0']] }; + }); + + await updateContact('c1', { + id: undefined, uid: null, addressBookIds: null, name: { full: 'Ann' }, + } as never); + + expect(mockRequest.mock.calls[0][0][0][1].update.c1).toEqual({ name: { full: 'Ann' } }); + }); + + it('still sends a uid or addressBookIds that has a value', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: { c1: null } }, '0']] }); + await updateContact('c1', { uid: 'urn:uuid:1', addressBookIds: { ab: true } }); + expect(mockRequest.mock.calls[0][0][0][1].update.c1).toEqual({ uid: 'urn:uuid:1', addressBookIds: { ab: true } }); + }); + + it('throws the SetError with its type and properties (0.16 refusing a blobId in media)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['ContactCard/set', { + notUpdated: { c1: { type: 'invalidProperties', description: 'blobIds in media is not supported.', properties: ['media'] } }, + }, '0']], + }); + const error = await updateContact('c1', { media: { photo: { kind: 'photo', blobId: 'b' } } }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(JMAPSetError); + expect(error).toMatchObject({ type: 'invalidProperties', properties: ['media'] }); + // The message the form shows stays as it was. + expect((error as Error).message).toBe('blobIds in media is not supported. (media)'); + }); + + it('keeps the type of a refused create', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['ContactCard/set', { + notCreated: { 'new-contact': { type: 'invalidProperties', description: 'blobIds in media is not supported.', properties: ['media'] } }, + }, '0']], + }); + await expect(createContact({ name: { full: 'A' } }, 'ab-1')) + .rejects.toMatchObject({ type: 'invalidProperties', properties: ['media'] }); + }); + }); + + describe('getContactMedia', () => { + it('reads only the media of the card', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['ContactCard/get', { + list: [{ id: 'c1', media: { photo: { blobId: 'b', kind: 'photo', mediaType: 'image/jpeg' } } }], + }, '0']], + }); + expect(await getContactMedia('c1', 'acc-team')).toEqual({ photo: { blobId: 'b', kind: 'photo', mediaType: 'image/jpeg' } }); + expect(mockRequest.mock.calls[0][0][0]).toEqual( + ['ContactCard/get', { accountId: 'acc-team', ids: ['c1'], properties: ['id', 'media'] }, '0'], + ); + }); + + it('tells a card without media from a card that is gone', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c1', media: null }] }, '0']] }); + expect(await getContactMedia('c1')).toBeNull(); + mockRequest.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [], notFound: ['c1'] }, '0']] }); + expect(await getContactMedia('c1')).toBeUndefined(); + }); }); describe('deleteContacts', () => { diff --git a/src/api/blob.ts b/src/api/blob.ts index 93b6d5a4..b3eb667a 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -1,6 +1,8 @@ import { File } from 'expo-file-system'; import type * as LegacyFileSystemTypes from 'expo-file-system/legacy'; import { jmapClient } from './jmap-client'; +import { batched } from './jmap-result'; +import { CAPABILITIES } from './types'; import { getClientCertAlias, secureFetch } from '../lib/client-cert'; // The legacy expo-file-system API (streaming upload tasks with progress and @@ -269,3 +271,109 @@ export function getDownloadUrl( .replace('{name}', encodeURIComponent(name ?? 'download')) .replace('{type}', encodeURIComponent(type ?? 'application/octet-stream')); } + +/** + * 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; + +/** What getBlobsAsBase64 brought back. */ +export interface BlobBatch { + /** Contents as base64 by blobId, for every blob the server returned. */ + data: Map; + /** Blobs left unfetched because a request failed; they can still be downloaded one by one. */ + failed: string[]; +} + +/** + * 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. + */ +function batchedBySize(items: T[], sizeOf: (item: T) => number | undefined, budget: number, maxCount: number): T[][] { + const limit = Math.max(1, Math.floor(maxCount)); + const result: T[][] = []; + let batch: T[] = []; + let total = 0; + for (const item of items) { + const size = sizeOf(item) ?? budget; + if (batch.length > 0 && (total + size > budget || batch.length >= limit)) { + result.push(batch); + batch = []; + total = 0; + } + batch.push(item); + total += size; + } + if (batch.length > 0) result.push(batch); + return result; +} + +/** + * The contents of many blobs as base64, through Blob/get (RFC 9404), for an + * export that needs a lot of them (webmail getBlobsAsBase64): 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`. + */ +export async function getBlobsAsBase64(blobIds: string[], accountId?: string): Promise { + if (!jmapClient.hasCapability(CAPABILITIES.BLOB)) return null; + const account = accountId || jmapClient.accountId; + const ids = [...new Set(blobIds)]; + const data = new Map(); + const blobGet = async (chunk: string[], property: 'size' | 'data:asBase64') => { + const res = await jmapClient.request( + [['Blob/get', { accountId: account, ids: chunk, properties: [property] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.BLOB], + ); + const [name, result] = res.methodResponses?.[0] ?? []; + if (name !== 'Blob/get') { + throw new Error(`Blob/get failed: ${result?.description || result?.type || 'unknown'}`); + } + return (result.list ?? []) as Array>; + }; + + let chunks: string[][] = ids.length > 0 ? [ids] : []; + if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) { + const sizes = new Map(); + try { + for (const chunk of batched(ids, jmapClient.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 (err) { + console.warn('[blob] Blob/get of sizes failed', err); + return { data, failed: ids }; + } + chunks = batchedBySize( + ids.filter((id) => sizes.has(id)), + (id) => sizes.get(id), + BLOB_BATCH_BYTES, + jmapClient.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 (err) { + console.warn('[blob] Blob/get failed', err); + return { data, failed: chunks.slice(i).flat() }; + } + } + return { data, failed: [] }; +} diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 82e6ba96..cdd9afb8 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -1,6 +1,7 @@ import { jmapClient } from './jmap-client'; +import { JMAPSetError } from './jmap-result'; import { CAPABILITIES } from './types'; -import type { ContactCard, AddressBook } from './types'; +import type { ContactCard, ContactMedia, AddressBook } from './types'; import { generateUUID } from '../lib/uuid'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]; @@ -36,6 +37,19 @@ function setErrorMessage(err: SetError | undefined, fallback: string): string { return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail; } +/** The SetError as a thrown error that keeps its type and properties. */ +function setError(err: SetError | undefined, fallback: string): JMAPSetError { + return new JMAPSetError(err?.type ?? 'setError', setErrorMessage(err, fallback), err?.properties); +} + +/** + * Properties an update never clears (webmail contactToWire). A null `id` fails + * the whole update ("The id property is immutable.") and so does a null + * `addressBookIds`; a null `uid` is refused by Stalwart 1.0 and on 0.16 + * silently deletes the card's UID. + */ +const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const; + /** * Primary account for the contacts capability (RFC 8620 `primaryAccounts`), * falling back to the mail primary. Contacts can live in a different account @@ -245,7 +259,7 @@ export async function createContact( }>(res); const created = result.created?.['new-contact']; if (!created?.id) { - throw new Error(setErrorMessage(result.notCreated?.['new-contact'], 'Failed to create contact')); + throw setError(result.notCreated?.['new-contact'], 'Failed to create contact'); } // The set response only carries server-set properties (id, created, // updated...). Re-fetch the full card so the store appends a complete row @@ -271,13 +285,38 @@ export async function updateContact( accountId?: string, ): Promise { const account = accountId || getContactsAccountId(); + const patch = { ...stripClientFields(changes) } as Record; + for (const key of NEVER_CLEARED_KEYS) { + if (patch[key] == null) delete patch[key]; + } const res = await jmapClient.request( - [['ContactCard/set', { accountId: account, update: { [id]: stripClientFields(changes) } }, '0']], + [['ContactCard/set', { accountId: account, update: { [id]: patch } }, '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[id]; - if (err) throw new Error(setErrorMessage(err, 'Failed to update contact')); + if (err) throw setError(err, 'Failed to update contact'); +} + +/** + * The card's `media` as the server has it now: null when it has none, + * undefined when the card is gone. Built on before a photo edit, since a + * blobId from an older copy of the card makes Stalwart 1.0 refuse the whole + * update ("The following blobIds do not exist or are not accessible"). + */ +export async function getContactMedia( + id: string, + accountId?: string, +): Promise | null | undefined> { + const account = accountId || getContactsAccountId(); + const res = await jmapClient.request( + [['ContactCard/get', { accountId: account, ids: [id], properties: ['id', 'media'] }, '0']], + USING, + ); + const card = (methodResult<{ list?: Array> }>(res).list ?? []) + .find((c) => c.id === id); + if (!card) return undefined; + return card.media ?? null; } /** diff --git a/src/api/jmap-result.ts b/src/api/jmap-result.ts index 294251bf..7cfe5364 100644 --- a/src/api/jmap-result.ts +++ b/src/api/jmap-result.ts @@ -133,3 +133,28 @@ export function batched(items: T[], size: number): T[][] { } return result; } + +/** + * A SetError (RFC 8620 §5.3): the server looked at one object of a /set call + * and refused it (`notCreated` / `notUpdated` / `notDestroyed`), as opposed + * to refusing the whole method call. The same type can come either way + * (`forbidden` is both), and only a SetError says something about that + * object. It is a `JMAPMethodError` with that name, so `instanceof` checks + * and the name-based classification in lib/network-error treat it as before. + */ +export class JMAPSetError extends JMAPMethodError { + /** The properties the server named (`invalidProperties`), if any. */ + properties?: string[]; + constructor(type: string, description?: string, properties?: string[]) { + super(type, description); + if (properties?.length) this.properties = properties; + } +} + +/** + * The properties a thrown SetError names, so a caller can tell + * `invalidProperties` on `media` from one on `name`; empty for anything else. + */ +export function jmapErrorProperties(err: unknown): string[] { + return err instanceof JMAPSetError ? err.properties ?? [] : []; +} diff --git a/src/api/types.ts b/src/api/types.ts index 1f42506d..da6c2c6e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -295,10 +295,18 @@ export interface ContactNote { author?: { name?: string; uri?: string }; } +// RFC 9553 Media. The bytes are either at `uri` (an https or data: URI) or in +// a blob: Stalwart 1.0 returns every embedded PHOTO, LOGO and SOUND as a +// `blobId` without `uri`, photos saved as data: URIs included, while 0.16 +// returns them as data: URIs. export interface ContactMedia { kind: 'photo' | 'sound' | 'logo' | string; - uri: string; + uri?: string; + blobId?: string; mediaType?: string; + label?: string; + pref?: number; + contexts?: Record; } export interface ContactRelation { @@ -677,4 +685,5 @@ export const CAPABILITIES = { PRINCIPALS: 'urn:ietf:params:jmap:principals', PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner', EMAIL_PUSH: 'urn:ietf:params:jmap:emailpush', + BLOB: 'urn:ietf:params:jmap:blob', } as const; diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 79309e21..321915c1 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { useShallow } from 'zustand/react/shallow'; import { generateEmailAvatarColor, getContactPhotoForEmail, @@ -9,6 +10,8 @@ import { hasFaviconFailed, markFaviconFailed, } from '../lib/avatar-utils'; +import type { ContactPhotoRef } from '../lib/contact-utils'; +import { useContactPhotoSource } from '../lib/use-contact-photo'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; @@ -23,34 +26,42 @@ interface SenderAvatarProps { * so a spam sender's domain is not contacted by merely listing the folder. */ disableImages?: boolean; + /** + * The photo of the contact this avatar stands for (see getContactPhotoRef). + * Left undefined, the address is looked up among the contacts; null means + * the contact has no photo, so no other card's photo is borrowed. + */ + photo?: ContactPhotoRef | null; } // Mirrors the webmail `Avatar` priority for the subset available on native: // address-book photo → company favicon (if sender domain is non-personal and // `senderFavicons` is on) → initials over HSL-hashed color. -export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) { +export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) { const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages; - // Selects a string, so only avatars whose photo changed re-render when the + // Compared shallowly, so only avatars whose photo changed re-render when the // contacts change; the index behind it is built once per contacts array. - const contactPhoto = useContactsStore((s) => getContactPhotoForEmail(s.contacts, email)); + const indexedPhoto = useContactsStore( + useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)), + ); + const contactPhoto = photo === undefined ? indexedPhoto : photo; + // A blob photo (Stalwart 1.0) has no source while it waits for its + // download or after it failed; the favicon or initials show meanwhile. + const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } = + useContactPhotoSource(disableImages ? null : contactPhoto); const [imgError, setImgError] = React.useState(false); - const [photoError, setPhotoError] = React.useState(false); React.useEffect(() => { setImgError(false); }, [email]); - React.useEffect(() => { - setPhotoError(false); - }, [contactPhoto]); - const primaryName = (name ?? '').split(',')[0].trim(); const initials = getEmailInitials(primaryName, email ?? undefined); const bgColor = generateEmailAvatarColor(primaryName, email ?? undefined); const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; - const showPhoto = !disableImages && !!contactPhoto && !photoError; + const showPhoto = !disableImages && !!photoSource; const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed; const handleError = React.useCallback(() => { @@ -71,14 +82,15 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const fontSize = Math.round(size * 0.4); - if (showPhoto && contactPhoto) { + if (showPhoto && photoSource) { return ( setPhotoError(true)} + onLoad={onPhotoLoad} + onError={onPhotoError} /> ); diff --git a/src/components/contacts/ContactListRow.tsx b/src/components/contacts/ContactListRow.tsx index b1e7853e..0ba5f45d 100644 --- a/src/components/contacts/ContactListRow.tsx +++ b/src/components/contacts/ContactListRow.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable, Image } from 'react-native'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Check, Users } from 'lucide-react-native'; import type { ContactCard } from '../../api/types'; import { getContactDisplayName, getContactPrimaryEmail, - getContactPhotoUri, + getContactPhotoRef, getPrimaryOrg, isGroup, } from '../../lib/contact-utils'; import SenderAvatar from '../SenderAvatar'; +import ContactPhoto from './ContactPhoto'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; @@ -37,11 +38,20 @@ export default function ContactListRow({ const name = getContactDisplayName(contact) || t('contacts.unnamed', 'Unnamed'); const email = getContactPrimaryEmail(contact); const org = getPrimaryOrg(contact); - const photoUri = getContactPhotoUri(contact); + const photo = getContactPhotoRef(contact); const group = isGroup(contact); const subtitle = secondaryText ?? (email || org); const tertiary = email && org && org !== name ? org : undefined; + // Also what a photo shows while it downloads (Stalwart 1.0 blobs) or when + // it can't be loaded. The card's own photo is the only one it may show. + const fallbackAvatar = group ? ( + + + + ) : ( + + ); return ( )} - {photoUri ? ( - - ) : group ? ( - - - - ) : ( - - )} + {photo ? ( + + ) : fallbackAvatar} {name} diff --git a/src/components/contacts/ContactPhoto.tsx b/src/components/contacts/ContactPhoto.tsx new file mode 100644 index 00000000..5d0b0000 --- /dev/null +++ b/src/components/contacts/ContactPhoto.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Image, type ImageStyle, type StyleProp } from 'react-native'; +import type { ContactPhotoRef } from '../../lib/contact-utils'; +import { useContactPhotoSource } from '../../lib/use-contact-photo'; + +interface ContactPhotoProps { + photo: ContactPhotoRef; + style: StyleProp; + /** + * Shown while a photo stored as a blob (Stalwart 1.0) waits for its + * download, and when the photo can't be loaded. + */ + fallback: React.ReactNode; +} + +export default function ContactPhoto({ photo, style, fallback }: ContactPhotoProps) { + const { source, onLoad, onError } = useContactPhotoSource(photo); + if (!source) return <>{fallback}; + return ( + + ); +} diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index da8cb4bc..197ea618 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -14,6 +14,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store'; import { contactsToVCard } from '../../lib/vcard'; import { isGroup } from '../../lib/contact-utils'; +import { inlineContactMediaForExport } from '../../lib/contact-media'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -83,7 +84,8 @@ export function ContactsSettings() { if (exportable.length === 0 || exporting) return; setExporting(true); try { - const vcf = contactsToVCard(exportable); + // Photos stored as blobs (Stalwart 1.0) are downloaded into the file. + const vcf = contactsToVCard(await inlineContactMediaForExport(exportable)); const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`; const path = `${FileSystem.cacheDirectory}${filename}`; await FileSystem.writeAsStringAsync(path, vcf); diff --git a/src/lib/__tests__/avatar-utils.test.ts b/src/lib/__tests__/avatar-utils.test.ts index 58072373..7bf18cae 100644 --- a/src/lib/__tests__/avatar-utils.test.ts +++ b/src/lib/__tests__/avatar-utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { getContactPhotoForEmail, getContactPhotoIndex } from '../avatar-utils'; -import type { ContactCard } from '../../api/types'; +import type { ContactCard, ContactMedia } from '../../api/types'; function card(id: string, emails: string[], photo?: string, mediaType?: string): ContactCard { return { @@ -11,14 +11,18 @@ function card(id: string, emails: string[], photo?: string, mediaType?: string): }; } +function withMedia(id: string, emails: string[], media: Record, extra: Partial = {}): ContactCard { + return { ...card(id, emails), media, ...extra }; +} + describe('contact photo avatars (#54)', () => { it('finds the photo of the contact that holds the sender address, case-insensitively', () => { const contacts = [ card('bob', ['bob@example.org']), card('alice', ['Alice.Nguyen@Example.com', 'alice@home.example'], 'data:image/png;base64,AAAA'), ]; - expect(getContactPhotoForEmail(contacts, 'alice.nguyen@example.com')).toBe('data:image/png;base64,AAAA'); - expect(getContactPhotoForEmail(contacts, ' ALICE@home.example ')).toBe('data:image/png;base64,AAAA'); + expect(getContactPhotoForEmail(contacts, 'alice.nguyen@example.com')).toEqual({ uri: 'data:image/png;base64,AAAA' }); + expect(getContactPhotoForEmail(contacts, ' ALICE@home.example ')).toEqual({ uri: 'data:image/png;base64,AAAA' }); expect(getContactPhotoForEmail(contacts, 'bob@example.org')).toBeUndefined(); expect(getContactPhotoForEmail(contacts, 'nobody@example.org')).toBeUndefined(); expect(getContactPhotoForEmail(contacts, undefined)).toBeUndefined(); @@ -27,7 +31,7 @@ describe('contact photo avatars (#54)', () => { it('normalizes the malformed data URIs Stalwart emits (#307)', () => { const contacts = [card('a', ['a@x.org'], 'data:base64,AAAA', 'image/png')]; - expect(getContactPhotoForEmail(contacts, 'a@x.org')).toBe('data:image/png;base64,AAAA'); + expect(getContactPhotoForEmail(contacts, 'a@x.org')).toEqual({ uri: 'data:image/png;base64,AAAA' }); }); it('lets the first contact with a photo win when an address is on several cards', () => { @@ -36,7 +40,7 @@ describe('contact photo avatars (#54)', () => { card('first', ['dup@x.org'], 'https://x.org/first.jpg'), card('second', ['dup@x.org'], 'https://x.org/second.jpg'), ]; - expect(getContactPhotoForEmail(contacts, 'dup@x.org')).toBe('https://x.org/first.jpg'); + expect(getContactPhotoForEmail(contacts, 'dup@x.org')).toEqual({ uri: 'https://x.org/first.jpg' }); }); it('builds the index once per contacts array', () => { @@ -48,7 +52,41 @@ describe('contact photo avatars (#54)', () => { const updated = [...contacts, card('b', ['b@x.org'], 'https://x.org/b.jpg')]; const next = getContactPhotoIndex(updated); expect(next).not.toBe(index); - expect(next.get('b@x.org')).toBe('https://x.org/b.jpg'); + expect(next.get('b@x.org')).toEqual({ uri: 'https://x.org/b.jpg' }); expect(index.has('b@x.org')).toBe(false); }); + + // Stalwart 1.0 returns an embedded photo as a blob, also one saved as a + // data: URI (live-checked): {blobId, kind, mediaType}, no uri. + it('finds a blob photo on Stalwart 1.0', () => { + const contacts = [ + withMedia('c1', ['carol@example.org'], { + photo: { blobId: 'iavlkor1rcdskxq', kind: 'photo', mediaType: 'image/jpeg' }, + logo: { blobId: 'icnkwsrhxxr97hh', kind: 'logo', mediaType: 'image/png' }, + }), + ]; + expect(getContactPhotoForEmail(contacts, 'carol@example.org')) + .toEqual({ blobId: 'iavlkor1rcdskxq', mediaType: 'image/jpeg' }); + }); + + it('downloads the photo of a card in a shared address book from the owner account (Stalwart 1.0)', () => { + const contacts = [ + withMedia('team:c1', ['dan@example.org'], { + photo: { blobId: 'b-dan', kind: 'photo', mediaType: 'image/png' }, + }, { isShared: true, accountId: 'team', originalId: 'c1' }), + ]; + expect(getContactPhotoForEmail(contacts, 'dan@example.org')) + .toEqual({ blobId: 'b-dan', mediaType: 'image/png', accountId: 'team' }); + }); + + it('ignores logos and sounds, and cards whose media carry neither uri nor blobId', () => { + const contacts = [ + withMedia('c1', ['erin@example.org'], { + logo: { blobId: 'b-logo', kind: 'logo' }, + sound: { uri: 'https://x.org/erin.ogg', kind: 'sound' }, + empty: { kind: 'photo' }, + }), + ]; + expect(getContactPhotoForEmail(contacts, 'erin@example.org')).toBeUndefined(); + }); }); diff --git a/src/lib/__tests__/contact-form.test.ts b/src/lib/__tests__/contact-form.test.ts new file mode 100644 index 00000000..f79834d9 --- /dev/null +++ b/src/lib/__tests__/contact-form.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { blankForm, contactToForm, formToPatch } from '../contact-form'; +import { mediaForFormSave } from '../contact-media'; +import type { ContactCard } from '../../api/types'; + +const JPEG_URI = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; +const PNG_URI = 'data:image/png;base64,iVBORw0KGgo='; + +// The same card as ContactCard/get returns it (live-checked): 0.16.23 keeps +// the data: URIs it was saved with, 1.0 returns each entry as a blob. +const base: ContactCard = { + id: 'm', + uid: 'urn:uuid:5f0b', + addressBookIds: { b: true }, + name: { components: [{ kind: 'given', value: 'Carol' }, { kind: 'surname', value: 'Jones' }], isOrdered: true, full: 'Carol Jones' }, + phones: { p1: { number: '+1000' } }, +}; +const CARDS: Array<[string, ContactCard]> = [ + ['0.16', { + ...base, + media: { + photo: { uri: JPEG_URI, kind: 'photo', mediaType: 'image/jpeg' }, + logo: { uri: PNG_URI, kind: 'logo', mediaType: 'image/png' }, + }, + }], + ['1.0', { + ...base, + media: { + photo: { blobId: 'iavlkor1rcdskxqjivvejt930lsiajj9ck1ejx02jimk31fltxo3qbilbq', kind: 'photo', mediaType: 'image/jpeg' }, + logo: { blobId: 'icnkwsrhxxr97hhh2kzjw9qbv97pji70mbf92wjmbrtkr2hnkcchybilbq', kind: 'logo', mediaType: 'image/png' }, + }, + }], +]; + +describe('contact form save', () => { + it.each(CARDS)('a phone-only edit leaves the photo alone on Stalwart %s', (_version, card) => { + const form = contactToForm(card, []); + form.phones = [{ number: '+2000', context: '', feature: '' }]; + + const patch = formToPatch(form, card, false, [card]); + + expect(patch.phones).toEqual({ p1: { number: '+2000' } }); + // Absent, not null: an update without `media` keeps every entry, where + // the old form sent the map without the photo, or `media: null`. + expect('media' in patch).toBe(false); + expect(mediaForFormSave(card.media, null, form.photo)).toBeNull(); + }); + + it.each(CARDS)('a new photo replaces the old one and keeps the logo on Stalwart %s', (_version, card) => { + const form = contactToForm(card, []); + form.photo = { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }; + + const edit = mediaForFormSave(card.media, { media: card.media }, form.photo); + + expect(edit?.photoKey).toBe('photo'); + expect(edit?.media).toEqual({ + photo: { kind: 'photo', uri: PNG_URI, mediaType: 'image/png' }, + logo: card.media!.logo, + }); + }); + + it.each(CARDS)('removing the photo keeps the logo on Stalwart %s', (_version, card) => { + const form = contactToForm(card, []); + form.photo = { kind: 'removed' }; + expect(mediaForFormSave(card.media, { media: card.media }, form.photo)).toEqual({ media: { logo: card.media!.logo } }); + }); + + it('removing the only photo deletes the media', () => { + const card: ContactCard = { ...base, media: { photo: { blobId: 'b', kind: 'photo' } } }; + expect(mediaForFormSave(card.media, { media: card.media }, { kind: 'removed' })).toEqual({ media: null }); + }); + + it('a new contact gets its photo under "photo"', () => { + const form = blankForm(); + form.given = 'Dan'; + form.photo = { kind: 'new', uri: JPEG_URI, mediaType: 'image/jpeg' }; + expect('media' in formToPatch(form, undefined, false, [])).toBe(false); + expect(mediaForFormSave(undefined, null, form.photo)).toEqual({ + media: { photo: { kind: 'photo', uri: JPEG_URI, mediaType: 'image/jpeg' } }, + photoKey: 'photo', + }); + }); + + it('starts every form with the photo unchanged', () => { + expect(blankForm().photo).toEqual({ kind: 'unchanged' }); + expect(contactToForm(CARDS[1][1], []).photo).toEqual({ kind: 'unchanged' }); + }); +}); diff --git a/src/lib/__tests__/contact-media.test.ts b/src/lib/__tests__/contact-media.test.ts new file mode 100644 index 00000000..1380a37a --- /dev/null +++ b/src/lib/__tests__/contact-media.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Blob capability of userc's account as the test servers advertise it +// (live-checked): Stalwart 1.0 lists ContactCard, 0.16.23 does not. +const BLOB_1_0 = { + maxSizeBlobSet: 7499488, + maxDataSources: 16, + supportedTypeNames: ['Email', 'Thread', 'SieveScript', 'CalendarEvent', 'ContactCard'], + supportedDigestAlgorithms: ['sha', 'sha-256', 'sha-512'], +}; +const BLOB_0_16 = { + maxSizeBlobSet: 7499488, + maxDataSources: 16, + supportedTypeNames: ['Email', 'Thread', 'SieveScript'], + supportedDigestAlgorithms: ['sha', 'sha-256', 'sha-512'], +}; + +const session = { + primaryAccounts: { 'urn:ietf:params:jmap:contacts': 'f' } as Record, + accounts: {} as Record }>, + capabilities: {} as Record, + downloadUrl: 'http://stw/jmap/download/{accountId}/{blobId}/{name}?accept={type}', +}; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + accountId: 'f', + serverUrl: 'http://stw', + get currentSession() { return session; }, + getAccountCapability: (urn: string, accountId?: string) => { + const caps = session.accounts[accountId ?? 'f']?.accountCapabilities; + if (caps && urn in caps) return caps[urn]; + return session.capabilities[urn]; + }, + hasCapability: (urn: string) => Boolean(session.capabilities[urn]), + request: vi.fn(), + getMaxObjectsInGet: () => 500, + fetchBlobArrayBuffer: vi.fn(), + }, +})); + +vi.mock('../../api/blob', async (importOriginal) => ({ + ...(await importOriginal()), + uploadBytes: vi.fn(), +})); + +import { jmapClient } from '../../api/jmap-client'; +import { uploadBytes } from '../../api/blob'; +import { JMAPMethodError, JMAPSetError } from '../../api/jmap-result'; +import type { ContactCard, ContactMedia } from '../../api/types'; +import { + inlineContactMedia, + inlineContactMediaForExport, + isBlobMediaRejection, + mediaForFormSave, + mediaForPhotoEdit, + resetContactBlobMediaRefusals, + supportsContactBlobMedia, + writeContactWithNewPhoto, +} from '../contact-media'; +import { contactsToVCard } from '../vcard'; + +const mockRequest = jmapClient.request as ReturnType; +const mockFetchBlob = jmapClient.fetchBlobArrayBuffer as ReturnType; +const mockUpload = uploadBytes as ReturnType; + +const JPEG_BYTES = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]; +const JPEG_URI = `data:image/jpeg;base64,${Buffer.from(JPEG_BYTES).toString('base64')}`; +const PNG_URI = 'data:image/png;base64,iVBORw0KGgo='; + +function useServer(version: '1.0' | '0.16', extraAccounts: Record> = {}) { + const blob = version === '1.0' ? BLOB_1_0 : BLOB_0_16; + session.accounts = { + f: { name: 'userc@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:blob': blob } }, + }; + for (const [id, caps] of Object.entries(extraAccounts)) { + session.accounts[id] = { name: id, isPersonal: false, accountCapabilities: caps }; + } + session.capabilities = { + 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 4 }, + 'urn:ietf:params:jmap:blob': {}, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetContactBlobMediaRefusals(); + useServer('1.0'); +}); + +describe('mediaForPhotoEdit', () => { + // A 1.0 card: every entry a blob, as ContactCard/get returns it. + const card10: Record = { + photo: { blobId: 'b-photo', kind: 'photo', mediaType: 'image/jpeg', pref: 1, label: 'Main', contexts: { work: true } }, + logo: { blobId: 'b-logo', kind: 'logo', mediaType: 'image/png' }, + }; + + it('puts a new photo in the place of the shown one, with its pref, label and contexts (Stalwart 1.0)', () => { + const edit = mediaForPhotoEdit(card10, card10, 'photo', { kind: 'new', uri: JPEG_URI, mediaType: 'image/jpeg' }); + expect(edit.photoKey).toBe('photo'); + expect(edit.media).toEqual({ + photo: { kind: 'photo', uri: JPEG_URI, mediaType: 'image/jpeg', pref: 1, label: 'Main', contexts: { work: true } }, + // Passed through as the server returned it: 1.0 resolves the blobId again. + logo: { blobId: 'b-logo', kind: 'logo', mediaType: 'image/png' }, + }); + }); + + it('passes the other entries of a 0.16 card through as data: URIs', () => { + const card016: Record = { + photo: { uri: JPEG_URI, kind: 'photo', mediaType: 'image/jpeg' }, + logo: { uri: PNG_URI, kind: 'logo', mediaType: 'image/png' }, + }; + const edit = mediaForPhotoEdit(card016, card016, 'photo', { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }); + expect(edit.media).toEqual({ + photo: { kind: 'photo', uri: PNG_URI, mediaType: 'image/png' }, + logo: { uri: PNG_URI, kind: 'logo', mediaType: 'image/png' }, + }); + }); + + it('gives a first photo a key no other entry uses', () => { + const base: Record = { photo: { kind: 'logo', blobId: 'b-logo' } }; + const edit = mediaForPhotoEdit(base, base, undefined, { kind: 'new', uri: JPEG_URI, mediaType: 'image/jpeg' }); + expect(edit.photoKey).toBe('photo1'); + expect(edit.media).toEqual({ ...base, photo1: { kind: 'photo', uri: JPEG_URI, mediaType: 'image/jpeg' } }); + expect(mediaForPhotoEdit({}, {}, undefined, { kind: 'new', uri: JPEG_URI, mediaType: 'image/jpeg' }).photoKey).toBe('photo'); + }); + + it('removes every photo the form knew, keeping logos, sounds and photos added since', () => { + const known: Record = { + p1: { blobId: 'b-1', kind: 'photo' }, + p2: { blobId: 'b-2', kind: 'photo', pref: 3 }, + logo: { blobId: 'b-logo', kind: 'logo' }, + }; + const base = { ...known, p3: { blobId: 'b-3', kind: 'photo' }, sound: { blobId: 'b-s', kind: 'sound' } }; + expect(mediaForPhotoEdit(base, known, 'p1', { kind: 'removed' }).media).toEqual({ + logo: { blobId: 'b-logo', kind: 'logo' }, + p3: { blobId: 'b-3', kind: 'photo' }, + sound: { blobId: 'b-s', kind: 'sound' }, + }); + }); + + it('sends null when the photo was the only entry', () => { + const only = { photo: { blobId: 'b-photo', kind: 'photo' } }; + expect(mediaForPhotoEdit(only, only, 'photo', { kind: 'removed' })).toEqual({ media: null }); + }); + + it('builds on the server copy, so a logo changed elsewhere is not sent back with its old blobId (Stalwart 1.0)', () => { + const known = { photo: { blobId: 'b-photo', kind: 'photo' }, logo: { blobId: 'b-logo-old', kind: 'logo' } }; + const current = { photo: { blobId: 'b-photo', kind: 'photo' }, logo: { blobId: 'b-logo-new', kind: 'logo' } }; + const edit = mediaForPhotoEdit(current, known, 'photo', { kind: 'new', uri: JPEG_URI, mediaType: 'image/jpeg' }); + expect(edit.media?.logo).toEqual({ blobId: 'b-logo-new', kind: 'logo' }); + }); +}); + +describe('mediaForFormSave', () => { + const known: Record = { photo: { blobId: 'b-photo', kind: 'photo', mediaType: 'image/jpeg' } }; + + it('leaves media out of the save when the photo is unchanged, on 0.16 and 1.0', () => { + expect(mediaForFormSave(known, null, { kind: 'unchanged' })).toBeNull(); + expect(mediaForFormSave({ photo: { uri: JPEG_URI, kind: 'photo' } }, null, { kind: 'unchanged' })).toBeNull(); + }); + + it('builds on the form copy when the server copy could not be read', () => { + const edit = mediaForFormSave(known, null, { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }); + expect(edit).toEqual({ media: { photo: { kind: 'photo', uri: PNG_URI, mediaType: 'image/png' } }, photoKey: 'photo' }); + }); + + it('replaces the server photo when the card came from the cache without media', () => { + const current = { media: { p9: { blobId: 'b-9', kind: 'photo', pref: 1 }, logo: { blobId: 'b-l', kind: 'logo' } } }; + const edit = mediaForFormSave(undefined, current, { kind: 'new', uri: PNG_URI, mediaType: 'image/png' }); + expect(edit?.photoKey).toBe('p9'); + expect(edit?.media).toEqual({ + p9: { kind: 'photo', uri: PNG_URI, mediaType: 'image/png', pref: 1 }, + logo: { blobId: 'b-l', kind: 'logo' }, + }); + }); + + it('does nothing when a photo the form never showed is removed', () => { + const current = { media: { photo: { blobId: 'b-photo', kind: 'photo' } } }; + expect(mediaForFormSave(undefined, current, { kind: 'removed' })).toBeNull(); + expect(mediaForFormSave({}, current, { kind: 'removed' })).toBeNull(); + }); + + it('removes the shown photo from the server copy', () => { + const current = { media: { ...known, logo: { blobId: 'b-l', kind: 'logo' } } }; + expect(mediaForFormSave(known, current, { kind: 'removed' })).toEqual({ media: { logo: { blobId: 'b-l', kind: 'logo' } } }); + }); +}); + +describe('supportsContactBlobMedia', () => { + it('reads ContactCard from the blob capability: yes on Stalwart 1.0, no on 0.16', () => { + expect(supportsContactBlobMedia('f')).toBe(true); + useServer('0.16'); + expect(supportsContactBlobMedia('f')).toBe(false); + }); + + it('says no for an account without the capability, whatever the session says', () => { + useServer('1.0', { team: {} }); + expect(supportsContactBlobMedia('team')).toBe(false); + }); +}); + +describe('isBlobMediaRejection', () => { + it('matches 0.16\'s refusal of a blobId in media', () => { + expect(isBlobMediaRejection(new JMAPSetError('invalidProperties', 'blobIds in media is not supported.', ['media']))).toBe(true); + expect(isBlobMediaRejection(new JMAPSetError('invalidProperties', 'x', ['media/photo']))).toBe(true); + }); + + it('does not match 1.0\'s stale blobId, which names no property, or other errors', () => { + expect(isBlobMediaRejection(new JMAPSetError( + 'invalidProperties', + 'The following blobIds do not exist or are not accessible: iavlkor1.', + ))).toBe(false); + expect(isBlobMediaRejection(new JMAPSetError('invalidProperties', 'x', ['name']))).toBe(false); + expect(isBlobMediaRejection(new JMAPMethodError('forbidden'))).toBe(false); + expect(isBlobMediaRejection(new Error('boom'))).toBe(false); + }); +}); + +describe('writeContactWithNewPhoto', () => { + const newCard = (): Partial => ({ + name: { full: 'Carol' }, + media: { + photo: { kind: 'photo', uri: JPEG_URI, mediaType: 'image/jpeg', pref: 1 }, + logo: { blobId: 'b-logo', kind: 'logo', mediaType: 'image/png' }, + }, + }); + + it('uploads the photo and sends its blobId on Stalwart 1.0', async () => { + mockUpload.mockResolvedValue({ blobId: 'eavlk-up', type: 'image/jpeg', size: JPEG_BYTES.length }); + const write = vi.fn().mockResolvedValue('saved'); + + await expect(writeContactWithNewPhoto(undefined, newCard(), 'photo', write)).resolves.toBe('saved'); + + expect(mockUpload).toHaveBeenCalledTimes(1); + const [bytes, type, accountId] = mockUpload.mock.calls[0]; + expect(Array.from(bytes as Uint8Array)).toEqual(JPEG_BYTES); + expect(type).toBe('image/jpeg'); + expect(accountId).toBe('f'); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].media).toEqual({ + photo: { kind: 'photo', blobId: 'eavlk-up', mediaType: 'image/jpeg', pref: 1 }, + logo: { blobId: 'b-logo', kind: 'logo', mediaType: 'image/png' }, + }); + }); + + it('uploads into the user\'s own account for a card in a shared address book (Stalwart 1.0)', async () => { + useServer('1.0', { team: { 'urn:ietf:params:jmap:blob': BLOB_1_0 } }); + mockUpload.mockResolvedValue({ blobId: 'eavlk-up', type: 'image/jpeg', size: 6 }); + const write = vi.fn().mockResolvedValue(undefined); + await writeContactWithNewPhoto('team', newCard(), 'photo', write); + expect(mockUpload.mock.calls[0][2]).toBe('f'); + expect(write.mock.calls[0][0].media.photo.blobId).toBe('eavlk-up'); + }); + + it('sends the data: URI and uploads nothing on 0.16', async () => { + useServer('0.16'); + const write = vi.fn().mockResolvedValue(undefined); + const card = newCard(); + await writeContactWithNewPhoto(undefined, card, 'photo', write); + expect(mockUpload).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith(card); + }); + + it('falls back to the data: URI once when the server refuses the blobId the way 0.16 does, and remembers it', async () => { + mockUpload.mockResolvedValue({ blobId: 'eavlk-up', type: 'image/jpeg', size: 6 }); + const refusal = new JMAPSetError('invalidProperties', 'blobIds in media is not supported. (media)', ['media']); + const write = vi.fn().mockRejectedValueOnce(refusal).mockResolvedValue('saved'); + const card = newCard(); + + await expect(writeContactWithNewPhoto(undefined, card, 'photo', write)).resolves.toBe('saved'); + expect(write).toHaveBeenCalledTimes(2); + expect(write.mock.calls[0][0].media.photo.blobId).toBe('eavlk-up'); + expect(write.mock.calls[1][0]).toBe(card); + + // The account gets no more uploads for the rest of the session. + expect(supportsContactBlobMedia('f')).toBe(false); + await writeContactWithNewPhoto(undefined, newCard(), 'photo', vi.fn().mockResolvedValue(undefined)); + expect(mockUpload).toHaveBeenCalledTimes(1); + }); + + it('does not retry other refusals', async () => { + mockUpload.mockResolvedValue({ blobId: 'eavlk-up', type: 'image/jpeg', size: 6 }); + const stale = new JMAPSetError('invalidProperties', 'The following blobIds do not exist or are not accessible: b-logo.'); + const write = vi.fn().mockRejectedValue(stale); + await expect(writeContactWithNewPhoto(undefined, newCard(), 'photo', write)).rejects.toBe(stale); + expect(write).toHaveBeenCalledTimes(1); + expect(supportsContactBlobMedia('f')).toBe(true); + }); + + it('saves the photo inline when the upload fails', async () => { + mockUpload.mockRejectedValue(new Error('Upload failed: 413')); + const write = vi.fn().mockResolvedValue(undefined); + const card = newCard(); + await writeContactWithNewPhoto(undefined, card, 'photo', write); + expect(write).toHaveBeenCalledWith(card); + }); +}); + +describe('inlineContactMedia (duplicate, move to another account)', () => { + it('turns blob entries into data: URIs of their bytes from the card account (Stalwart 1.0)', async () => { + mockFetchBlob.mockResolvedValue(new Uint8Array(JPEG_BYTES).buffer); + const media = await inlineContactMedia({ + isShared: true, + accountId: 'team', + media: { + photo: { blobId: 'b-photo', kind: 'photo', mediaType: 'image/jpeg', pref: 1 }, + link: { uri: 'https://x.org/a.png', kind: 'logo' }, + }, + }, 'keep'); + expect(mockFetchBlob).toHaveBeenCalledWith('b-photo', 'photo', 'image/jpeg', 'team'); + expect(media).toEqual({ + photo: { kind: 'photo', mediaType: 'image/jpeg', pref: 1, uri: JPEG_URI }, + link: { uri: 'https://x.org/a.png', kind: 'logo' }, + }); + }); + + it('keeps an entry it could not download when asked to', async () => { + mockFetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404')); + const entry = { blobId: 'b-photo', kind: 'photo' }; + expect(await inlineContactMedia({ media: { photo: entry } }, 'keep')).toEqual({ photo: entry }); + expect(await inlineContactMedia({ media: { photo: entry } }, 'drop')).toEqual({}); + }); + + it('downloads nothing for a 0.16 card', async () => { + const media = { photo: { uri: JPEG_URI, kind: 'photo' } }; + expect(await inlineContactMedia({ media }, 'keep')).toBe(media); + expect(mockFetchBlob).not.toHaveBeenCalled(); + }); +}); + +describe('inlineContactMediaForExport', () => { + const card = (id: string, media: Record, extra: Partial = {}): ContactCard => + ({ id, addressBookIds: { b: true }, name: { full: id }, media, ...extra }); + + it('fetches the photos of Stalwart 1.0 cards in one Blob/get and writes them into the vCard', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Blob/get', { + accountId: 'f', + list: [ + { id: 'b-1', 'data:asBase64': Buffer.from(JPEG_BYTES).toString('base64') }, + { id: 'b-2', 'data:asBase64': 'iVBORw0KGgo=' }, + ], + }, '0']], + }); + const cards = [ + card('One', { photo: { blobId: 'b-1', kind: 'photo', mediaType: 'image/jpeg' } }), + card('Two', { photo: { blobId: 'b-2', kind: 'photo', mediaType: 'image/png' } }), + ]; + + const out = await inlineContactMediaForExport(cards); + + expect(mockRequest).toHaveBeenCalledTimes(1); + const [calls, using] = mockRequest.mock.calls[0]; + expect(calls[0]).toEqual(['Blob/get', { accountId: 'f', ids: ['b-1', 'b-2'], properties: ['data:asBase64'] }, '0']); + expect(using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob']); + expect(out[0].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/jpeg', uri: JPEG_URI } }); + expect(out[1].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBORw0KGgo=' } }); + + const vcf = contactsToVCard(out); + expect(vcf).toContain(`PHOTO;ENCODING=b;TYPE=image/jpeg:${Buffer.from(JPEG_BYTES).toString('base64')}`); + expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/png:iVBORw0KGgo='); + expect(mockFetchBlob).not.toHaveBeenCalled(); + }); + + // Blob/get answers as Stalwart 1.0 does: sizes or contents of the blobs it + // has, the rest in notFound. + function blobServer(sizes: Record) { + mockRequest.mockImplementation(async (calls: Array<[string, { ids: string[]; properties: string[] }, string]>) => { + const [, args] = calls[0]; + const found = args.ids.filter((id) => id in sizes); + const list = found.map((id) => (args.properties[0] === 'size' + ? { id, size: sizes[id] } + : { id, 'data:asBase64': 'AAAA' })); + return { methodResponses: [['Blob/get', { accountId: 'f', list, notFound: args.ids.filter((id) => !(id in sizes)) }, '0']] }; + }); + } + + it('asks for sizes first when many photos could make one response too large', async () => { + const MiB = 1024 * 1024; + blobServer({ 'b-1': 3 * MiB, 'b-2': 3 * MiB, 'b-3': 300, 'b-4': 400 }); + const cards = ['b-1', 'b-2', 'b-3', 'b-4', 'b-gone'].map((id) => + card(id, { photo: { blobId: id, kind: 'photo', mediaType: 'image/jpeg' } })); + + const out = await inlineContactMediaForExport(cards); + + const asked = mockRequest.mock.calls.map(([calls]) => [calls[0][1].properties[0], calls[0][1].ids]); + // Sizes in one call, then requests of about 4 MiB; a blob the server no + // longer has is not asked for again. + expect(asked).toEqual([ + ['size', ['b-1', 'b-2', 'b-3', 'b-4', 'b-gone']], + ['data:asBase64', ['b-1']], + ['data:asBase64', ['b-2', 'b-3', 'b-4']], + ]); + expect(out.map((c) => c.media?.photo?.uri)).toEqual([ + ...Array(4).fill('data:image/jpeg;base64,AAAA'), + undefined, + ]); + expect(out[4].media).toEqual({}); + }); + + it('downloads one by one what a refused Blob/get could not deliver', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['error', { type: 'unknownMethod', description: 'Method Blob/get requires capability urn:ietf:params:jmap:blob' }, '0']], + }); + mockFetchBlob.mockResolvedValueOnce(new Uint8Array(JPEG_BYTES).buffer).mockRejectedValueOnce(new Error('Failed to fetch blob: 404')); + const cards = [ + card('One', { photo: { blobId: 'b-1', kind: 'photo', mediaType: 'image/jpeg' } }), + card('Two', { photo: { blobId: 'b-2', kind: 'photo', mediaType: 'image/png' } }), + ]; + + const out = await inlineContactMediaForExport(cards); + + expect(mockFetchBlob).toHaveBeenCalledTimes(2); + expect(out[0].media?.photo?.uri).toBe(JPEG_URI); + expect(out[1].media).toEqual({}); + }); + + it('keeps the blob capability out of `using` when the session lacks it', async () => { + delete session.capabilities['urn:ietf:params:jmap:blob']; + mockFetchBlob.mockResolvedValue(new Uint8Array(JPEG_BYTES).buffer); + const out = await inlineContactMediaForExport([card('One', { photo: { blobId: 'b-1', kind: 'photo', mediaType: 'image/jpeg' } })]); + expect(mockRequest).not.toHaveBeenCalled(); + expect(out[0].media?.photo?.uri).toBe(JPEG_URI); + }); + + it('fetches shared cards from their owner account', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Blob/get', { list: [{ id: 'b-1', 'data:asBase64': 'AAAA' }] }, '0']] }); + await inlineContactMediaForExport([ + card('team:One', { photo: { blobId: 'b-1', kind: 'photo' } }, { isShared: true, accountId: 'team', originalId: 'One' }), + ]); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('team'); + }); + + it('leaves 0.16 cards, whose photos are data: URIs, alone', async () => { + const cards = [card('One', { photo: { uri: JPEG_URI, kind: 'photo', mediaType: 'image/jpeg' } })]; + const out = await inlineContactMediaForExport(cards); + expect(out[0]).toBe(cards[0]); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockFetchBlob).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/__tests__/contact-photo-loader.test.ts b/src/lib/__tests__/contact-photo-loader.test.ts new file mode 100644 index 00000000..4020eea0 --- /dev/null +++ b/src/lib/__tests__/contact-photo-loader.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const session = { + primaryAccounts: { 'urn:ietf:params:jmap:contacts': 'f' } as Record, + accounts: {}, + capabilities: { 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 4 } } as Record, + downloadUrl: 'http://stw/jmap/download/{accountId}/{blobId}/{name}?accept={type}', +}; +let connected = true; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + accountId: 'f', + get currentSession() { return connected ? session : null; }, + get authHeader() { return 'Basic dXNlcmM6c2VjcmV0'; }, + }, +})); + +import { + ContactPhotoLoad, + PhotoDownloadGate, + contactPhotoSource, + photoDownloadLimit, + resetContactPhotoLoads, + type ContactPhotoState, +} from '../contact-photo-loader'; + +beforeEach(() => { + resetContactPhotoLoads(); + connected = true; + session.capabilities = { 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 4 } }; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('contactPhotoSource', () => { + it('downloads a Stalwart 1.0 photo with the Authorization header, from the contacts account', () => { + expect(contactPhotoSource({ blobId: 'iavlkor1', mediaType: 'image/jpeg' })).toEqual({ + uri: 'http://stw/jmap/download/f/iavlkor1/photo?accept=image%2Fjpeg', + headers: { Authorization: 'Basic dXNlcmM6c2VjcmV0' }, + }); + }); + + it('downloads the photo of a shared card from the owner account', () => { + expect(contactPhotoSource({ blobId: 'b', accountId: 'team' })?.uri) + .toBe('http://stw/jmap/download/team/b/photo?accept=application%2Foctet-stream'); + }); + + it('has nothing to show without a session', () => { + connected = false; + expect(contactPhotoSource({ blobId: 'b' })).toBeNull(); + }); +}); + +describe('photoDownloadLimit', () => { + it('leaves half of maxConcurrentRequests to JMAP calls', () => { + expect(photoDownloadLimit()).toBe(2); + session.capabilities = { 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 1 } }; + expect(photoDownloadLimit()).toBe(1); + session.capabilities = { 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 10 } }; + expect(photoDownloadLimit()).toBe(5); + session.capabilities = {}; + expect(photoDownloadLimit()).toBe(2); + }); +}); + +describe('PhotoDownloadGate', () => { + it('runs at most `limit` at once and starts the next when one is done', () => { + const gate = new PhotoDownloadGate(() => 2); + const started: string[] = []; + const releaseA = gate.acquire(() => started.push('a')); + gate.acquire(() => started.push('b')); + const releaseC = gate.acquire(() => started.push('c')); + gate.acquire(() => started.push('d')); + expect(started).toEqual(['a', 'b']); + + // A request that left the screen while waiting never starts. + releaseC(); + releaseA(); + expect(started).toEqual(['a', 'b', 'd']); + // Releasing twice gives nothing back twice. + releaseA(); + gate.acquire(() => started.push('e')); + expect(started).toEqual(['a', 'b', 'd']); + }); +}); + +describe('ContactPhotoLoad', () => { + /** A started load and every state it went through, the one `start` returned first. */ + function track(key: string, gate: PhotoDownloadGate): { load: ContactPhotoLoad; states: ContactPhotoState[] } { + const changes: ContactPhotoState[] = []; + const load = new ContactPhotoLoad(key, (s) => changes.push(s), gate); + const first = load.start(); + // A free slot shows the photo while start() runs; start() returns that state too. + const states = changes.length > 0 ? changes : [first]; + return { load, states }; + } + + it('waits for a slot, and a photo that loaded once skips the queue', () => { + const gate = new PhotoDownloadGate(() => 1); + const statesB: ContactPhotoState[] = []; + const a = new ContactPhotoLoad('url-a', () => undefined, gate); + const b = new ContactPhotoLoad('url-b', (s) => statesB.push(s), gate); + expect(a.start()).toBe('show'); + expect(b.start()).toBe('waiting'); + + a.loaded(); + expect(statesB).toEqual(['show']); + + // Scrolled back: served from the image cache, no slot needed. + const again = new ContactPhotoLoad('url-a', () => undefined, new PhotoDownloadGate(() => 0)); + expect(again.start()).toBe('show'); + }); + + it('tries a failed download once more a second later, then gives up for two minutes', () => { + vi.useFakeTimers(); + const gate = new PhotoDownloadGate(() => 2); + const { load, states } = track('url-x', gate); + expect(states).toEqual(['show']); + + // Refused over the concurrency limit, say. + load.failed(); + expect(states).toEqual(['show', 'waiting']); + vi.advanceTimersByTime(1000); + expect(states).toEqual(['show', 'waiting', 'show']); + + load.failed(); + expect(states).toEqual(['show', 'waiting', 'show', 'failed']); + + // Another avatar of the same photo doesn't ask again for a while. + expect(new ContactPhotoLoad('url-x', () => undefined, gate).start()).toBe('failed'); + vi.advanceTimersByTime(2 * 60_000 + 1); + expect(new ContactPhotoLoad('url-x', () => undefined, gate).start()).toBe('show'); + }); + + it('gives its slot back when it leaves the screen, also during the retry pause', () => { + vi.useFakeTimers(); + const gate = new PhotoDownloadGate(() => 1); + const a = new ContactPhotoLoad('url-a', () => undefined, gate); + const statesB: ContactPhotoState[] = []; + const b = new ContactPhotoLoad('url-b', (s) => statesB.push(s), gate); + a.start(); + b.start(); + a.failed(); + // a's slot went to b while a waits to retry. + expect(statesB).toEqual(['show']); + a.dispose(); + vi.advanceTimersByTime(1000); + b.dispose(); + const c = new ContactPhotoLoad('url-c', () => undefined, gate); + expect(c.start()).toBe('show'); + }); +}); diff --git a/src/lib/__tests__/contact-utils.test.ts b/src/lib/__tests__/contact-utils.test.ts index 9a6b86ca..c7fe97a9 100644 --- a/src/lib/__tests__/contact-utils.test.ts +++ b/src/lib/__tests__/contact-utils.test.ts @@ -1,10 +1,13 @@ import { describe, it, expect } from 'vitest'; import { deriveFullName, + findContactPhotoEntry, getContactDisplayName, getContactSortName, getCustomFullName, + getContactPhotoRef, getContactPhotoUri, + hasContactPhoto, normalizeContactPhotoUri, partialDateToString, stringToPartialDate, @@ -39,6 +42,48 @@ describe('normalizeContactPhotoUri (#307)', () => { }); }); +describe('contact photo refs', () => { + const card = (media: ContactCard['media'], extra: Partial = {}): ContactCard => + ({ id: 'c', addressBookIds: {}, media, ...extra }); + + it('reads the data: URI Stalwart 0.16 returns', () => { + const c = card({ photo: { kind: 'photo', uri: 'data:image/jpeg;base64,AAAA', mediaType: 'image/jpeg' } }); + expect(getContactPhotoRef(c)).toEqual({ uri: 'data:image/jpeg;base64,AAAA' }); + expect(hasContactPhoto(c)).toBe(true); + }); + + it('reads the blob Stalwart 1.0 returns instead of a uri', () => { + const c = card({ photo: { blobId: 'iavlkor1', kind: 'photo', mediaType: 'image/jpeg' } }); + expect(getContactPhotoRef(c)).toEqual({ blobId: 'iavlkor1', mediaType: 'image/jpeg' }); + expect(hasContactPhoto(c)).toBe(true); + // No uri to hand out for a blob. + expect(getContactPhotoUri(c)).toBeUndefined(); + }); + + it('picks the photo with the lowest pref, the first one on a tie', () => { + const c = card({ + logo: { kind: 'logo', blobId: 'b-logo', pref: 1 }, + p1: { kind: 'photo', blobId: 'b-1' }, + p2: { kind: 'photo', blobId: 'b-2', pref: 5 }, + p3: { kind: 'photo', blobId: 'b-3', pref: 2 }, + p4: { kind: 'photo', blobId: 'b-4', pref: 2 }, + }); + expect(findContactPhotoEntry(c)?.[0]).toBe('p3'); + expect(findContactPhotoEntry(card({ a: { kind: 'photo', uri: 'https://x/a.jpg' }, b: { kind: 'photo', uri: 'https://x/b.jpg' } }))?.[0]) + .toBe('a'); + }); + + it('has no photo without a photo entry that carries a uri or blobId', () => { + expect(hasContactPhoto(card({ logo: { kind: 'logo', blobId: 'b' }, p: { kind: 'photo' } }))).toBe(false); + expect(hasContactPhoto(card(undefined))).toBe(false); + }); + + it('names the owner account for a card of a shared address book', () => { + const c = card({ photo: { blobId: 'b', kind: 'photo' } }, { isShared: true, accountId: 'team' }); + expect(getContactPhotoRef(c)).toEqual({ blobId: 'b', accountId: 'team' }); + }); +}); + describe('partialDateToString', () => { it.each([ [{ year: 1990, month: 5, day: 4 }, '1990-05-04'], diff --git a/src/lib/__tests__/vcard.test.ts b/src/lib/__tests__/vcard.test.ts index da74bca2..f812dde6 100644 --- a/src/lib/__tests__/vcard.test.ts +++ b/src/lib/__tests__/vcard.test.ts @@ -345,3 +345,69 @@ describe('organization-only cards (issue #701)', () => { expect(parseVCard(vcf)).toHaveLength(1); }); }); + +describe('MEMBER export', () => { + const group = (members: Record): ContactCard => ({ + id: 'g1', + addressBookIds: {}, + kind: 'group', + name: { full: 'Team' }, + members, + }); + + it('writes a member key that is already a URI as it is', () => { + // Groups made in the app key members by uid (urn:uuid:); CardDAV groups + // keep whatever MEMBER value the client sent. + const vcf = generateVCard([group({ 'urn:uuid:0f3c9d2e': true, 'mailto:ann@example.com': true })]); + const lines = vcf.split('\r\n'); + expect(lines).toContain('MEMBER:urn:uuid:0f3c9d2e'); + expect(lines).toContain('MEMBER:mailto:ann@example.com'); + expect(vcf).not.toContain('urn:uuid:urn:uuid:'); + }); + + it('prefixes a bare key with urn:uuid:', () => { + expect(generateVCard([group({ '0f3c9d2e': true, gone: false })]).split('\r\n').filter((l) => l.startsWith('MEMBER:'))) + .toEqual(['MEMBER:urn:uuid:0f3c9d2e']); + }); + + it('round-trips members through import and export', () => { + const [parsed] = parseVCard([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Team', + 'KIND:group', + 'MEMBER:urn:uuid:member-1', + 'MEMBER:mailto:ann@example.com', + 'END:VCARD', + ].join('\r\n')); + const [reparsed] = parseVCard(generateVCard([parsed])); + expect(reparsed.members).toEqual(parsed.members); + expect(generateVCard([parsed])).toContain('MEMBER:urn:uuid:member-1'); + }); +}); + +describe('photo export', () => { + const withMedia = (media: ContactCard['media']): ContactCard => ({ + id: 'c1', addressBookIds: {}, name: { full: 'Carol' }, media, + }); + + it('writes photos whose data: URI names no media type, as Stalwart 0.16 stores them', () => { + const vcf = generateVCard([withMedia({ + a: { kind: 'photo', uri: 'data:;base64,AAAA', mediaType: 'image/png' }, + b: { kind: 'logo', uri: 'data:base64,BBBB', mediaType: 'image/jpeg' }, + c: { kind: 'photo', uri: 'data:image/jpeg;base64,CCCC' }, + d: { kind: 'photo', uri: 'data:;base64,DDDD' }, + })]); + const lines = vcf.split('\r\n'); + expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/png:AAAA'); + expect(lines).toContain('LOGO;ENCODING=b;TYPE=image/jpeg:BBBB'); + expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:CCCC'); + expect(lines).toContain('PHOTO;ENCODING=b:DDDD'); + }); + + it('skips a Stalwart 1.0 blob photo that was not downloaded into the card', () => { + const vcf = generateVCard([withMedia({ photo: { kind: 'photo', blobId: 'iavlkor1', mediaType: 'image/jpeg' } })]); + expect(vcf).not.toContain('PHOTO'); + expect(vcf).toContain('FN:Carol'); + }); +}); diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 469ffc5d..a977656b 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -11,7 +11,7 @@ // export them separately to keep the match 1:1. import type { ContactCard } from '../api/types'; -import { getContactPhotoUri } from './contact-utils'; +import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils'; // --- Account avatar ------------------------------------------------------- @@ -157,20 +157,20 @@ export function markFaviconFailed(domain: string): void { // --- Contact photos ------------------------------------------------------- // // Webmail `components/ui/avatar.tsx` looks the sender up in the address book -// and shows their contact photo ahead of favicons and initials. The webmail -// scans every contact per avatar; the mail list renders dozens of avatars, so -// build one lowercased email → photo index per contacts array instead and -// reuse it until the store hands out a new array. +// and shows their contact photo ahead of favicons and initials. The mail list +// renders dozens of avatars, so build one lowercased email → photo index per +// contacts array and reuse it until the store hands out a new array. A photo +// is a URI (0.16) or a blob to download (Stalwart 1.0). -const contactPhotoIndexCache = new WeakMap>(); +const contactPhotoIndexCache = new WeakMap>(); -export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map { +export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map { let index = contactPhotoIndexCache.get(contacts); if (index) return index; index = new Map(); for (const contact of contacts) { if (!contact.emails) continue; - const photo = getContactPhotoUri(contact); + const photo = getContactPhotoRef(contact); if (!photo) continue; for (const entry of Object.values(contact.emails)) { const key = entry?.address?.trim().toLowerCase(); @@ -185,7 +185,7 @@ export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map kinds.includes(c.kind as string)); + return found?.value || ''; +} + +function addressToFlat(a: ContactAddress): AddressDraft { + if (a.components && a.components.length > 0) { + const collect = (kind: string) => a.components!.filter((c) => c.kind === kind).map((c) => c.value).join(' '); + const number = collect('number'); + const name = collect('name'); + return { + street: [number, name].filter(Boolean).join(' ') || a.street || '', + locality: collect('locality') || a.locality || '', + region: collect('region') || a.region || '', + postcode: collect('postcode') || a.postcode || '', + country: collect('country') || a.country || '', + context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', + }; + } + return { + street: a.street || '', + locality: a.locality || '', + region: a.region || '', + postcode: a.postcode || '', + country: a.country || '', + context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', + }; +} + +export function contactToForm(contact: ContactCard, memberIds: string[]): FormState { + const prefix = findNameComponent(contact, 'title', 'prefix'); + const given = findNameComponent(contact, 'given'); + const middle = findNameComponent(contact, 'given2', 'additional', 'middle'); + const surname = findNameComponent(contact, 'surname'); + const suffix = findNameComponent(contact, 'generation', 'suffix'); + + const nicknames = contact.nicknames + ? Object.values(contact.nicknames).map((n) => n.name || '').filter(Boolean) + : []; + + const emails = contact.emails ? Object.values(contact.emails).map((e) => ({ + address: e.address, + context: e.contexts?.work ? 'work' : e.contexts?.private ? 'private' : '', + })) : []; + + const phones = contact.phones ? Object.values(contact.phones).map((p) => ({ + number: p.number, + context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '', + feature: + p.features?.cell ? 'cell' + : p.features?.fax ? 'fax' + : p.features?.pager ? 'pager' + : p.features?.video ? 'video' + : p.features?.text ? 'text' + : p.features?.voice ? 'voice' + : '', + })) : []; + + const addresses = contact.addresses ? Object.values(contact.addresses).map(addressToFlat) : []; + + // Pair organization and titles into a single "work" record per index. + const rawOrgs = contact.organizations ? Object.values(contact.organizations) : []; + const titles = contact.titles ? Object.values(contact.titles) : []; + const orgs: OrgDraft[] = []; + const maxLen = Math.max(rawOrgs.length, titles.length); + for (let i = 0; i < maxLen; i++) { + const o = rawOrgs[i]; + const t = titles[i]; + orgs.push({ + name: o?.name || '', + department: o?.units?.[0]?.name || '', + jobTitle: t?.kind !== 'role' ? t?.name || '' : '', + role: t?.kind === 'role' ? t.name : '', + }); + } + + const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries).map((a) => ({ + kind: a.kind || 'birth', + date: partialDateToString(a.date), + })) : []; + + const online = contact.onlineServices ? Object.values(contact.onlineServices).map((s) => ({ + uri: s.uri || '', + service: s.service || '', + label: s.label || '', + })) : []; + + const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo).map((pi) => ({ + kind: pi.kind || 'hobby', + level: pi.level || '', + value: pi.value || '', + })) : []; + + const notes = contact.notes ? Object.values(contact.notes).map((n) => ({ note: n.note })) : []; + + const keywords = contact.keywords + ? Object.keys(contact.keywords).filter((k) => contact.keywords![k]) + : []; + + const addressBookId = Object.keys(contact.addressBookIds || {}) + .find((id) => contact.addressBookIds[id]) || ''; + + const grammaticalGender = contact.speakToAs?.grammaticalGender || ''; + const pronouns = contact.speakToAs?.pronouns + ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || '' + : ''; + + // A card may describe an organization instead of a person (RFC 9553 kind + // "org"). Older cards predate the explicit kind, so fall back to "has an org + // name but no personal name". + const isOrg = contact.kind + ? contact.kind === 'org' + : !(given || surname) && !!rawOrgs[0]?.name; + // Only a display name of its own goes in the field; a derived one is + // derived again on save, so it can't go stale when the name changes. + const full = getCustomFullName(contact, isOrg ? rawOrgs[0]?.name : undefined); + + return { + isOrg, prefix, given, middle, surname, suffix, full, + nicknames: nicknames.length > 0 ? nicknames : [''], + emails: emails.length > 0 ? emails : [{ address: '', context: '' }], + phones, addresses, orgs, anniversaries, online, + personalInfo, notes, keywords, addressBookId, + photo: { kind: 'unchanged' }, + grammaticalGender, pronouns, + calendarUri: contact.calendarUri || '', + schedulingUri: contact.schedulingUri || '', + freeBusyUri: contact.freeBusyUri || '', + members: memberIds, + }; +} + +/** Group-membership key for a card (RFC 9553 members are keyed by UID). */ +function memberKey(contact: ContactCard): string { + return contact.uid || contact.originalId || contact.id; +} + +function memberKeyMatches(key: string, contact: ContactCard): boolean { + const bare = key.startsWith('urn:uuid:') ? key.slice(9) : key; + const bareUid = contact.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact.uid; + return key === contact.id || bare === contact.id + || (!!contact.originalId && (key === contact.originalId || bare === contact.originalId)) + || (!!contact.uid && (key === contact.uid || bare === bareUid)); +} + +/** + * Build the JMAP patch. In edit mode every collection the form owns that ended + * up empty is sent as `null` so the server clears it - omitting the key would + * mean "unchanged" and the removed phone/address/note would come right back. + * `media` is not part of it: see mediaForPhotoEdit. + */ +export function formToPatch( + form: FormState, + existing: ContactCard | undefined, + asGroup: boolean, + allContacts: ContactCard[], +): Partial { + const isEdit = !!existing; + const orgName = form.orgs[0]?.name.trim() || ''; + + // Organization cards carry no personal name components; the org name goes + // into `name.full` below. A group name lives in `given`. + const components: Array<{ kind: string; value: string }> = []; + if (!form.isOrg) { + if (form.prefix.trim()) components.push({ kind: 'title', value: form.prefix.trim() }); + if (form.given.trim()) components.push({ kind: 'given', value: form.given.trim() }); + if (form.middle.trim()) components.push({ kind: 'given2', value: form.middle.trim() }); + if (form.surname.trim()) components.push({ kind: 'surname', value: form.surname.trim() }); + if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() }); + } + + // Always send `name.full`: the vCard FN is built from it and is mandatory + // (#430). Without personal name components, carry the organization name + // so servers and other clients have something to display. + const full = form.full.trim() || (form.isOrg ? orgName : deriveFullName(components)); + const name: ContactCard['name'] | undefined = + components.length > 0 || full + ? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) } + : undefined; + + const nicknames: Record = {}; + form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => { + nicknames[`n${i}`] = { name: n }; + }); + + const emails: Record = {}; + form.emails.filter((e) => e.address.trim()).forEach((e, i) => { + emails[`e${i + 1}`] = { + address: e.address.trim(), + ...(e.context ? { contexts: { [e.context]: true } } : {}), + }; + }); + + const phones: Record = {}; + form.phones.filter((p) => p.number.trim()).forEach((p, i) => { + phones[`p${i + 1}`] = { + number: p.number.trim(), + ...(p.context ? { contexts: { [p.context]: true } } : {}), + ...(p.feature ? { features: { [p.feature]: true } } : {}), + }; + }); + + const addresses: Record = {}; + form.addresses + .filter((a) => a.street.trim() || a.locality.trim() || a.country.trim() || a.region.trim() || a.postcode.trim()) + .forEach((a, i) => { + const comps: Array<{ kind: string; value: string }> = []; + if (a.street.trim()) comps.push({ kind: 'name', value: a.street.trim() }); + if (a.locality.trim()) comps.push({ kind: 'locality', value: a.locality.trim() }); + if (a.region.trim()) comps.push({ kind: 'region', value: a.region.trim() }); + if (a.postcode.trim()) comps.push({ kind: 'postcode', value: a.postcode.trim() }); + if (a.country.trim()) comps.push({ kind: 'country', value: a.country.trim() }); + addresses[`a${i + 1}`] = { + components: comps, + isOrdered: true, + defaultSeparator: ', ', + ...(a.context ? { contexts: { [a.context]: true } } : {}), + }; + }); + + const organizations: Record = {}; + const titles: Record = {}; + form.orgs.forEach((o, i) => { + if (o.name.trim() || o.department.trim()) { + const units = o.department.trim() ? [{ name: o.department.trim() }] : undefined; + organizations[`o${i + 1}`] = { + ...(o.name.trim() ? { name: o.name.trim() } : {}), + ...(units ? { units } : {}), + }; + } + if (o.jobTitle.trim()) { + titles[`t${i + 1}`] = { name: o.jobTitle.trim(), kind: 'title' }; + } + if (o.role.trim()) { + titles[`r${i + 1}`] = { name: o.role.trim(), kind: 'role' }; + } + }); + + const anniversaries: Record = {}; + form.anniversaries.forEach((a, i) => { + const date = stringToPartialDate(a.date); + if (!date) return; + anniversaries[`an${i + 1}`] = { kind: a.kind as ContactAnniversary['kind'], date }; + }); + + const onlineServices: Record = {}; + form.online.filter((s) => s.uri.trim()).forEach((s, i) => { + onlineServices[`os${i + 1}`] = { + uri: s.uri.trim(), + ...(s.service.trim() ? { service: s.service.trim() } : {}), + ...(s.label.trim() ? { label: s.label.trim() } : {}), + }; + }); + + const personalInfo: Record = {}; + form.personalInfo.filter((p) => p.value.trim()).forEach((p, i) => { + personalInfo[`pi${i + 1}`] = { + kind: p.kind as ContactPersonalInfo['kind'], + value: p.value.trim(), + ...(p.level ? { level: p.level as 'high' | 'medium' | 'low' } : {}), + }; + }); + + const notes: Record = {}; + form.notes.forEach((n, i) => { + if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() }; + }); + + const keywords: Record = {}; + form.keywords.forEach((k) => { + if (k.trim()) keywords[k.trim()] = true; + }); + + const speakToAs = + form.grammaticalGender || form.pronouns.trim() + ? { + ...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}), + ...(form.pronouns.trim() + ? { pronouns: { p0: { pronouns: form.pronouns.trim() } } } + : {}), + } + : undefined; + + // Collections: value when non-empty; `null` on edit when the card had one + // before (clears it server-side); omitted otherwise. + const collection = (key: keyof ContactCard, value: Record): Record => { + if (Object.keys(value).length > 0) return { [key]: value }; + if (isEdit && existing?.[key] !== undefined) return { [key]: null }; + return {}; + }; + const scalar = (key: keyof ContactCard, value: string): Record => { + if (value) return { [key]: value }; + if (isEdit && existing?.[key] !== undefined) return { [key]: null }; + return {}; + }; + + // Only send `kind` when this form owns the answer: switching a card between + // person and organization. Leave other kinds (group, location, ...) untouched. + const kind: Partial = asGroup + ? { kind: 'group' } + : form.isOrg + ? { kind: 'org' } + : existing?.kind === 'org' ? { kind: 'individual' } : {}; + + const patch: Record = { + ...kind, + ...(name ? { name } : isEdit && existing?.name ? { name: null } : {}), + ...collection('nicknames', nicknames), + ...collection('emails', emails), + ...collection('phones', phones), + ...collection('addresses', addresses), + ...collection('organizations', organizations), + ...collection('titles', titles), + ...collection('anniversaries', anniversaries), + ...collection('onlineServices', onlineServices), + ...collection('personalInfo', personalInfo), + ...collection('notes', notes), + ...collection('keywords', keywords), + ...(speakToAs ? { speakToAs } : isEdit && existing?.speakToAs ? { speakToAs: null } : {}), + ...scalar('calendarUri', form.calendarUri.trim()), + ...scalar('schedulingUri', form.schedulingUri.trim()), + ...scalar('freeBusyUri', form.freeBusyUri.trim()), + // No `media`: an update without it keeps every entry on the server, and + // the form adds one only when the photo changed (mediaForPhotoEdit). A + // map built here from the card's copy deleted a photo stored as a blob + // (Stalwart 1.0) on every save. + }; + + if (asGroup) { + // Start from the stored map so members we cannot resolve locally survive; + // drop the resolved ones that were deselected and add the new picks. + const members: Record = { ...(existing?.members || {}) }; + const selected = form.members + .map((id) => allContacts.find((c) => c.id === id)) + .filter((c): c is ContactCard => !!c); + for (const key of Object.keys(members)) { + const owner = allContacts.find((c) => memberKeyMatches(key, c)); + if (owner && !selected.some((s) => s.id === owner.id)) delete members[key]; + } + for (const contact of selected) { + if (!Object.keys(members).some((key) => memberKeyMatches(key, contact))) { + members[memberKey(contact)] = true; + } + } + patch.members = members; + } + + return patch as Partial; +} diff --git a/src/lib/contact-media.ts b/src/lib/contact-media.ts new file mode 100644 index 00000000..1341ab38 --- /dev/null +++ b/src/lib/contact-media.ts @@ -0,0 +1,321 @@ +// Contact media that live as blobs, and writing a newly picked photo. Port +// of the webmail's lib/contact-media.ts. +// +// Stalwart 1.0 returns every embedded PHOTO, LOGO and SOUND as +// `{kind, blobId, mediaType}`; 0.16 returns data: URIs and refuses a blobId in +// `media` ("blobIds in media is not supported."). + +import { getBlobsAsBase64, uploadBytes } from '../api/blob'; +import { getContactsAccountId } from '../api/contacts'; +import { jmapClient } from '../api/jmap-client'; +import { JMAPMethodError, jmapErrorProperties } from '../api/jmap-result'; +import { CAPABILITIES } from '../api/types'; +import type { ContactCard, ContactMedia } from '../api/types'; +import { findContactPhotoEntry } from './contact-utils'; +import { bytesToBase64, dataUriToBytes } from './data-uri'; + +/** What the user did to the photo in the contact form. */ +export type PhotoEdit = + | { kind: 'unchanged' } + | { kind: 'new'; uri: string; mediaType: string } + | { kind: 'removed' }; + +/** Options for saving a card from the contact form. */ +export interface ContactSaveOptions { + /** Key of the `media` entry holding a photo the user just picked, as a data: URI. */ + photoKey?: string; +} + +type BlobMedia = ContactMedia & { blobId: string }; + +function isBlobOnly(entry: ContactMedia | null | undefined): entry is BlobMedia { + return !!entry?.blobId && !entry.uri; +} + +export function hasBlobMedia(media: Record | null | undefined): boolean { + return Object.values(media ?? {}).some(isBlobOnly); +} + +/** The account a card's blobs live in: the owner's for a shared book. */ +function mediaAccountId(contact: Pick): string { + return contact.isShared && contact.accountId ? contact.accountId : getContactsAccountId(); +} + +/** A `media` key for a new photo that no other entry uses. */ +function freeMediaKey(media: Record): string { + let key = 'photo'; + for (let n = 1; key in media; n++) key = `photo${n}`; + 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. + * + * The other entries go back as the server returned them: a data: URI on 0.16, + * a blobId on 1.0, which resolves it again. A map replaces every entry on the + * server, and `null` (nothing left) deletes them all. + */ +export function mediaForPhotoEdit( + base: Record, + known: Record, + shownKey: string | undefined, + edit: Exclude, +): { media: Record | null; photoKey?: string } { + if (edit.kind === 'removed') { + const others = Object.fromEntries(Object.entries(base).filter( + ([key, entry]) => !(entry?.kind === 'photo' && known[key]?.kind === 'photo'), + )); + return { media: Object.keys(others).length > 0 ? others : null }; + } + const shown = shownKey ? (base[shownKey]?.kind === 'photo' ? base[shownKey] : known[shownKey]) : undefined; + const photoKey = shownKey && (!base[shownKey] || base[shownKey]?.kind === 'photo') ? shownKey : freeMediaKey(base); + const photo: ContactMedia = { + kind: 'photo', + uri: edit.uri, + mediaType: edit.mediaType, + ...(shown?.pref !== undefined ? { pref: shown.pref } : {}), + ...(shown?.label !== undefined ? { label: shown.label } : {}), + ...(shown?.contexts !== undefined ? { contexts: shown.contexts } : {}), + }; + const media: Record = {}; + for (const [key, entry] of Object.entries(base)) media[key] = key === photoKey ? photo : entry; + media[photoKey] = photo; + return { media, photoKey }; +} + +/** + * What the contact form saves in `media`, and the key of a newly picked + * photo; null when `media` stays out of the save, which keeps every entry on + * the server (an unchanged photo). + * + * `known` is the card's media as the form got it, `current` as the server has + * it now (null when it couldn't be read). The contacts cache is stored without + * media, so a card opened from it before a refresh knows none: a new photo + * then replaces the photo the server has, and removing one the form never + * showed changes nothing. + */ +export function mediaForFormSave( + known: Record | undefined, + current: Pick | null, + edit: PhotoEdit, +): { media: Record | null; photoKey?: string } | null { + if (edit.kind === 'unchanged') return null; + const formCopy = edit.kind === 'new' ? known ?? current?.media ?? {} : known ?? {}; + const shownKey = findContactPhotoEntry({ media: formCopy })?.[0]; + if (edit.kind === 'removed' && !shownKey) return null; + return mediaForPhotoEdit(current ? current.media ?? {} : formCopy, formCopy, shownKey, edit); +} + +// Accounts (per server) whose ContactCard/set refused a blobId in media +// although the blob capability suggested it takes one. +const blobMediaRefused = new Set(); + +function refusalKey(accountId: string): string { + return `${jmapClient.serverUrl ?? ''}|${accountId}`; +} + +/** + * Whether ContactCard/set in the account takes a `media` entry that carries + * a blobId. Stalwart 1.0 embeds contact media as blobs and lists ContactCard + * among the blob capability's supportedTypeNames (RFC 9404); 0.16 lists only + * Email, Thread and SieveScript, and refuses such an entry. A refusal seen at + * runtime wins for the rest of the session. + */ +export function supportsContactBlobMedia(accountId: string): boolean { + if (blobMediaRefused.has(refusalKey(accountId))) return false; + const blob = jmapClient.getAccountCapability(CAPABILITIES.BLOB, accountId) as + | { supportedTypeNames?: unknown } + | undefined; + return Array.isArray(blob?.supportedTypeNames) && blob.supportedTypeNames.includes('ContactCard'); +} + +/** + * 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"]`). 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 error instanceof JMAPMethodError && error.type === 'invalidProperties' + && jmapErrorProperties(error).some((p) => p === 'media' || p.startsWith('media/')); +} + +/** + * Saves a card whose `media[photoKey]` is a photo the user just picked, as a + * data: URI, through `write` (a ContactCard/set create or update in + * `accountId`, the contacts account when undefined). + * + * Where the account's cards carry blobs (supportsContactBlobMedia), the photo + * is uploaded and sent as `{kind, blobId, mediaType}`. The upload goes into + * the user's own account whichever account the card is written to: Stalwart + * 1.0 resolves an uploaded blob only for members of the account it was + * uploaded to, so one uploaded into the owner's account of a shared address + * book would not resolve for a sharee, while the user's own always does. + * + * Otherwise, and when the upload fails, the data: URI goes out as it is. If + * the server still refuses the blobId the way 0.16 does, the same write is + * sent once more with the data: URI, and the account gets no more uploads for + * the rest of the session. + */ +export async function writeContactWithNewPhoto( + accountId: string | undefined, + card: Partial, + photoKey: string, + write: (card: Partial) => Promise, +): Promise { + const photo = card.media?.[photoKey]; + const targetAccountId = accountId || getContactsAccountId(); + if (!photo?.uri || !supportsContactBlobMedia(targetAccountId)) return write(card); + const decoded = dataUriToBytes(photo.uri, photo.mediaType); + if (!decoded) return write(card); + + let blobId: string; + try { + ({ blobId } = await uploadBytes(decoded.bytes, decoded.type, getContactsAccountId())); + } catch (err) { + console.warn('[contacts] photo upload failed, saving it inline', err); + return write(card); + } + + const { uri: _uri, ...entry } = photo; + const withBlob: ContactMedia = { ...entry, mediaType: photo.mediaType ?? decoded.type, blobId }; + try { + return await write({ ...card, media: { ...card.media, [photoKey]: withBlob } }); + } catch (err) { + if (!isBlobMediaRejection(err)) throw err; + blobMediaRefused.add(refusalKey(targetAccountId)); + return write(card); + } +} + +/** `media` with each blob entry replaced by a data: URI from `uriOf`, or dropped/kept when it has none. */ +async function withInlineBlobs( + media: Record, + uriOf: (entry: BlobMedia) => Promise, + onFailure: 'drop' | 'keep', +): Promise> { + const out: Record = {}; + // One entry at a time: a card has a few, and each may be a download. + for (const [key, entry] of Object.entries(media)) { + if (!entry?.blobId) { + out[key] = entry; + continue; + } + const { blobId: _blobId, ...rest } = entry; + if (entry.uri) { + out[key] = rest; + continue; + } + const uri = await uriOf(entry as BlobMedia); + if (uri) out[key] = { ...rest, uri }; + else if (onFailure === 'keep') out[key] = entry; + } + return out; +} + +async function downloadAsDataUri(entry: BlobMedia, accountId: string): Promise { + try { + const type = entry.mediaType || 'application/octet-stream'; + const buffer = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.kind, type, accountId); + return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`; + } catch (err) { + console.warn('[contacts] media download failed', err); + return null; + } +} + +/** + * `media` of `contact` with every blob entry turned into a data: URI of its + * bytes, for a copy of the card written elsewhere (duplicate, move to another + * account), which cannot count on the source card's blobId resolving. An + * entry whose download fails is dropped, or with `onFailure: 'keep'` sent on + * as it is. + */ +export async function inlineContactMedia( + contact: Pick, + onFailure: 'drop' | 'keep', +): Promise | undefined> { + const { media } = contact; + if (!media || !hasBlobMedia(media)) return media ?? undefined; + const accountId = mediaAccountId(contact); + return withInlineBlobs(media, (entry) => downloadAsDataUri(entry, accountId), onFailure); +} + +/** + * The cards with their blob media turned into data: URIs, for a vCard export, + * which carries the bytes themselves. + * + * The blobs of all cards of one account come through a few batched Blob/get + * requests (getBlobsAsBase64): one download each would hit Stalwart's limit of + * 1000 requests a minute on a large address book, and a 429 stops every other + * request for a while. What the batch can't deliver (no blob capability, a + * refused request) is downloaded one by one; a blob that can't be downloaded + * at all is left out of the export. Never rejects. + */ +export async function inlineContactMediaForExport(contacts: ContactCard[]): Promise { + const byAccount = new Map>(); + for (const contact of contacts) { + if (!hasBlobMedia(contact.media)) continue; + const accountId = mediaAccountId(contact); + let entries = byAccount.get(accountId); + if (!entries) { + entries = new Map(); + byAccount.set(accountId, entries); + } + for (const entry of Object.values(contact.media ?? {})) { + if (isBlobOnly(entry)) entries.set(entry.blobId, entry); + } + } + + const uris = new Map(); + for (const [accountId, entries] of byAccount) { + let oneByOne = [...entries.values()]; + const batch = await getBlobsAsBase64([...entries.keys()], accountId).catch((err: unknown) => { + console.warn('[contacts] batched download of contact media failed', err); + return null; + }); + if (batch) { + for (const [blobId, base64] of batch.data) { + const entry = entries.get(blobId); + if (entry) uris.set(`${accountId}|${blobId}`, `data:${entry.mediaType || 'application/octet-stream'};base64,${base64}`); + } + const failed = new Set(batch.failed); + oneByOne = oneByOne.filter((entry) => failed.has(entry.blobId)); + } + for (const entry of oneByOne) { + const uri = await downloadAsDataUri(entry, accountId); + if (uri) uris.set(`${accountId}|${entry.blobId}`, uri); + } + } + + const out: ContactCard[] = []; + for (const contact of contacts) { + if (!contact.media || !hasBlobMedia(contact.media)) { + out.push(contact); + continue; + } + const accountId = mediaAccountId(contact); + const media = await withInlineBlobs( + contact.media, + async (entry) => uris.get(`${accountId}|${entry.blobId}`) ?? null, + 'drop', + ); + out.push({ ...contact, media }); + } + return out; +} + +/** Forgets the accounts that refused blob media (tests). */ +export function resetContactBlobMediaRefusals(): void { + blobMediaRefused.clear(); +} diff --git a/src/lib/contact-photo-loader.ts b/src/lib/contact-photo-loader.ts new file mode 100644 index 00000000..be3e2b7a --- /dev/null +++ b/src/lib/contact-photo-loader.ts @@ -0,0 +1,214 @@ +// Contact photos stored as blobs (Stalwart 1.0), shown by the platform image +// loader from the download URL with the Authorization header, the way file +// thumbnails are. +// +// Those downloads count against the same per-user budget as JMAP calls: +// Stalwart runs maxConcurrentRequests (4) requests per user at a time and +// refuses the surplus outright, and the mail list asks for a photo per sender +// row. Hence: +// - at most half of maxConcurrentRequests photo downloads run at once, so JMAP +// calls keep the rest; +// - a photo that loaded once skips the queue: Stalwart marks downloads +// immutable, so the image cache answers; +// - a failed download is tried once more a little later, since a refusal over +// the limit says nothing about the photo; after that the failure is kept for +// a while instead of retried on every render. + +import { getDownloadUrl } from '../api/blob'; +import { getContactsAccountId } from '../api/contacts'; +import { jmapClient } from '../api/jmap-client'; +import { CAPABILITIES } from '../api/types'; +import type { ContactPhotoRef } from './contact-utils'; + +const RETRY_DELAY_MS = 1000; +const FAILURE_TTL_MS = 2 * 60_000; +const MAX_REMEMBERED = 500; + +/** An Image source for a blob photo. */ +export interface ContactPhotoSource { + uri: string; + headers?: Record; +} + +type BlobPhoto = Extract; + +/** + * The download URL and Authorization header of a blob photo, or null while + * there is no session to build them from. + */ +export function contactPhotoSource(photo: BlobPhoto): ContactPhotoSource | null { + try { + const accountId = photo.accountId || getContactsAccountId(); + return { + uri: getDownloadUrl(photo.blobId, 'photo', photo.mediaType || 'application/octet-stream', accountId), + headers: { Authorization: jmapClient.authHeader }, + }; + } catch { + return null; + } +} + +/** Photo downloads allowed at once: half the server's request budget. */ +export function photoDownloadLimit(): number { + const core = jmapClient.currentSession?.capabilities?.[CAPABILITIES.CORE] as + | { maxConcurrentRequests?: unknown } + | undefined; + const max = typeof core?.maxConcurrentRequests === 'number' && core.maxConcurrentRequests > 0 + ? core.maxConcurrentRequests + : 4; + return Math.max(1, Math.floor(max / 2)); +} + +/** A counting semaphore for photo downloads, first come first served. */ +export class PhotoDownloadGate { + private running = 0; + private readonly queue: Array<() => void> = []; + + constructor(private readonly limit: () => number) {} + + /** + * Calls `start` once a download slot is free. The returned function frees + * the slot again, or drops the request while it still waits; calling it + * more than once is harmless. + */ + acquire(start: () => void): () => void { + let state: 'queued' | 'running' | 'done' = 'queued'; + const run = () => { + state = 'running'; + this.running++; + start(); + }; + if (this.running < this.limit()) run(); + else this.queue.push(run); + return () => { + if (state === 'done') return; + if (state === 'queued') { + const at = this.queue.indexOf(run); + if (at >= 0) this.queue.splice(at, 1); + } else { + this.running--; + } + state = 'done'; + this.pump(); + }; + } + + private pump(): void { + while (this.running < this.limit() && this.queue.length > 0) { + this.queue.shift()!(); + } + } +} + +const photoDownloads = new PhotoDownloadGate(photoDownloadLimit); + +// Download URLs of photos the image loader has shown (oldest first), and of +// photos that failed twice, with when to try them again. +const loadedPhotos = new Set(); +const failedUntil = new Map(); + +function rememberLoaded(key: string): void { + loadedPhotos.delete(key); + loadedPhotos.add(key); + if (loadedPhotos.size > MAX_REMEMBERED) { + loadedPhotos.delete(loadedPhotos.values().next().value as string); + } +} + +function recentlyFailed(key: string): boolean { + const until = failedUntil.get(key); + if (until === undefined) return false; + if (until > Date.now()) return true; + failedUntil.delete(key); + return false; +} + +/** + * `waiting`: queued for a download slot (show the fallback); `show`: render + * the image; `failed`: show the fallback, the photo can't be loaded now. + */ +export type ContactPhotoState = 'waiting' | 'show' | 'failed'; + +/** What a photo shows before its load starts: known photos show at once. */ +export function initialContactPhotoState(key: string): ContactPhotoState { + if (loadedPhotos.has(key)) return 'show'; + if (recentlyFailed(key)) return 'failed'; + return 'waiting'; +} + +/** One image of a blob photo on screen, from queueing to loaded or failed. */ +export class ContactPhotoLoad { + private state: ContactPhotoState = 'waiting'; + private release: (() => void) | null = null; + private retried = false; + private retryTimer: ReturnType | null = null; + private disposed = false; + + constructor( + private readonly key: string, + private readonly onChange: (state: ContactPhotoState) => void, + private readonly gate: PhotoDownloadGate = photoDownloads, + ) {} + + /** Starts loading and returns the state to show now. */ + start(): ContactPhotoState { + this.state = initialContactPhotoState(this.key); + if (this.state === 'waiting') this.enqueue(); + return this.state; + } + + /** The image loaded: from now on it comes from the image cache. */ + loaded(): void { + rememberLoaded(this.key); + this.freeSlot(); + } + + /** The image failed to load: try once more later, then give up for a while. */ + failed(): void { + this.freeSlot(); + loadedPhotos.delete(this.key); + if (this.disposed) return; + if (!this.retried) { + this.retried = true; + this.set('waiting'); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + if (!this.disposed) this.enqueue(); + }, RETRY_DELAY_MS); + return; + } + failedUntil.set(this.key, Date.now() + FAILURE_TTL_MS); + this.set('failed'); + } + + /** The image left the screen: give its slot to the next photo. */ + dispose(): void { + this.disposed = true; + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.freeSlot(); + } + + private enqueue(): void { + this.state = 'waiting'; + this.release = this.gate.acquire(() => this.set('show')); + } + + private set(state: ContactPhotoState): void { + if (this.disposed || state === this.state) return; + this.state = state; + this.onChange(state); + } + + private freeSlot(): void { + const release = this.release; + this.release = null; + release?.(); + } +} + +/** Forgets which photos loaded or failed (tests). */ +export function resetContactPhotoLoads(): void { + loadedPhotos.clear(); + failedUntil.clear(); +} diff --git a/src/lib/contact-utils.ts b/src/lib/contact-utils.ts index 526ec554..f414d63c 100644 --- a/src/lib/contact-utils.ts +++ b/src/lib/contact-utils.ts @@ -1,5 +1,6 @@ import type { ContactCard, + ContactMedia, AnniversaryDate, NameComponent, PartialDate, @@ -148,16 +149,56 @@ export function normalizeContactPhotoUri(uri: string, mediaType?: string): strin return uri; } -export function getContactPhotoUri(contact: ContactCard): string | undefined { - if (!contact.media) return undefined; - for (const media of Object.values(contact.media)) { - if (media.kind === 'photo' && media.uri) { - return normalizeContactPhotoUri(media.uri, media.mediaType); - } +/** + * A contact photo as something to show: a URI (https, or a data: URI with its + * media type fixed), or a blob that still has to be downloaded from the + * card's account. Stalwart 1.0 returns every embedded photo as a blob, 0.16 + * as a data: URI. + */ +export type ContactPhotoRef = + | { uri: string } + | { blobId: string; mediaType?: string; accountId?: string }; + +/** + * The card's photo entry and its key: the `media` entry of kind photo with + * the lowest `pref` (RFC 9553: unset counts as least preferred), the first + * one on a tie. + */ +export function findContactPhotoEntry( + contact: Pick, +): [string, ContactMedia] | undefined { + let found: [string, ContactMedia] | undefined; + for (const [key, media] of Object.entries(contact.media ?? {})) { + if (media?.kind !== 'photo' || !(media.uri || media.blobId)) continue; + if (!found || (media.pref ?? Infinity) < (found[1].pref ?? Infinity)) found = [key, media]; + } + return found; +} + +export function getContactPhotoRef(contact: ContactCard): ContactPhotoRef | undefined { + const media = findContactPhotoEntry(contact)?.[1]; + if (media?.uri) return { uri: normalizeContactPhotoUri(media.uri, media.mediaType) }; + if (media?.blobId) { + return { + blobId: media.blobId, + ...(media.mediaType ? { mediaType: media.mediaType } : {}), + // Cards of a shared address book keep their blobs in the owner's account. + ...(contact.isShared && contact.accountId ? { accountId: contact.accountId } : {}), + }; } return undefined; } +export function hasContactPhoto(contact: ContactCard): boolean { + return !!findContactPhotoEntry(contact); +} + +/** The photo URI, for callers that cannot download a blob photo. */ +export function getContactPhotoUri(contact: ContactCard): string | undefined { + const ref = getContactPhotoRef(contact); + return ref && 'uri' in ref ? ref.uri : undefined; +} + /** * Lossless PartialDate → input string: `1990-05-04`, `1990-05`, `1990`, * `--05-04`, `--05`, `---04`. Never pads a missing month/day with `01` (that diff --git a/src/lib/data-uri.ts b/src/lib/data-uri.ts new file mode 100644 index 00000000..82eac7ce --- /dev/null +++ b/src/lib/data-uri.ts @@ -0,0 +1,46 @@ +// base64 data: URIs, as contact photos carry them (webmail lib/data-uri.ts). +// +// Stalwart writes three shapes: `data:;base64,…`, `data:;base64,…` +// (0.16.x) and `data:base64,…` (0.16.0, no parameter separator). The last two +// carry no media type, so it comes from the card's `mediaType` when known. + +/** The media type (if any) and base64 payload of a base64 data: URI. */ +export function parseBase64DataUri(uri: string): { type?: string; data: string } | null { + if (!uri.toLowerCase().startsWith('data:')) return null; + const comma = uri.indexOf(','); + if (comma < 0) return null; + const header = uri.slice(5, comma).split(';'); + if (header[header.length - 1].trim().toLowerCase() !== 'base64') return null; + const type = header.length > 1 ? header[0].trim() : ''; + return { type: type || undefined, data: uri.slice(comma + 1) }; +} + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk))); + } + return btoa(binary); +} + +/** + * The bytes of a base64 data: URI with their media type; `fallbackType` + * applies when the URI names none. Null for anything else. + */ +export function dataUriToBytes( + uri: string, + fallbackType?: string, +): { bytes: Uint8Array; type: string } | null { + const parsed = parseBase64DataUri(uri); + if (!parsed) return null; + let binary: string; + try { + binary = atob(parsed.data); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, type: parsed.type || fallbackType || 'application/octet-stream' }; +} diff --git a/src/lib/use-contact-photo.ts b/src/lib/use-contact-photo.ts new file mode 100644 index 00000000..cddbb98b --- /dev/null +++ b/src/lib/use-contact-photo.ts @@ -0,0 +1,75 @@ +import React from 'react'; +import type { ContactPhotoRef } from './contact-utils'; +import { + ContactPhotoLoad, + contactPhotoSource, + initialContactPhotoState, + type ContactPhotoSource, + type ContactPhotoState, +} from './contact-photo-loader'; + +export interface ContactPhotoImage { + /** + * The Image source, or undefined while a blob photo waits for a download + * slot and once the photo failed to load: show the fallback then. + */ + source: ContactPhotoSource | undefined; + onLoad: () => void; + onError: () => void; +} + +/** + * The source to show for a contact photo. A URI (0.16) is used as it is; a + * blob (Stalwart 1.0) is downloaded through the queue in contact-photo-loader + * while the component is mounted. + */ +export function useContactPhotoSource(photo: ContactPhotoRef | null | undefined): ContactPhotoImage { + const uri = photo && 'uri' in photo ? photo.uri : undefined; + const blob = photo && 'blobId' in photo ? photo : undefined; + const blobId = blob?.blobId; + const mediaType = blob?.mediaType; + const accountId = blob?.accountId; + + // One source object per photo and credentials, so a re-render doesn't make + // the Image load it again, while a refreshed token reaches the next load. + const blobSourceNow = blobId ? contactPhotoSource({ blobId, mediaType, accountId }) : null; + const auth = blobSourceNow?.headers?.Authorization; + const key = blobSourceNow?.uri; + const blobSource = React.useMemo( + () => (key ? { uri: key, headers: auth ? { Authorization: auth } : undefined } : undefined), + [key, auth], + ); + const uriSource = React.useMemo(() => (uri ? { uri } : undefined), [uri]); + + const [failedUri, setFailedUri] = React.useState(null); + const [blobState, setBlobState] = React.useState<{ key?: string; state: ContactPhotoState }>( + () => ({ key, state: key ? initialContactPhotoState(key) : 'waiting' }), + ); + const loadRef = React.useRef(null); + + React.useEffect(() => { + if (!key) return undefined; + const load = new ContactPhotoLoad(key, (state) => setBlobState({ key, state })); + loadRef.current = load; + setBlobState({ key, state: load.start() }); + return () => { + load.dispose(); + if (loadRef.current === load) loadRef.current = null; + }; + }, [key]); + + const onLoad = React.useCallback(() => { + loadRef.current?.loaded(); + }, []); + const onError = React.useCallback(() => { + if (uri) setFailedUri(uri); + else loadRef.current?.failed(); + }, [uri]); + + if (uriSource) { + return { source: failedUri === uri ? undefined : uriSource, onLoad, onError }; + } + // Until the effect has started this photo's load, go by what is known of it. + const state = blobState.key === key ? blobState.state : key ? initialContactPhotoState(key) : 'waiting'; + return { source: state === 'show' ? blobSource : undefined, onLoad, onError }; +} diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts index 71704d9e..6b95b847 100644 --- a/src/lib/vcard.ts +++ b/src/lib/vcard.ts @@ -7,6 +7,7 @@ import type { } from '../api/types'; import { generateUUID } from './uuid'; import { deriveFullName } from './contact-utils'; +import { parseBase64DataUri } from './data-uri'; // Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string function anniversaryDateToVcardString(date: AnniversaryDate): string { @@ -1453,19 +1454,29 @@ function generateSingleVCard(contact: ContactCard): string { if (contact.members) { for (const memberId of Object.keys(contact.members)) { if (contact.members[memberId]) { - lines.push(`MEMBER:urn:uuid:${memberId}`); + // Keys are URIs already for groups made here (members are keyed by + // uid, a urn:uuid:) and for groups from CardDAV clients (Stalwart + // keeps MEMBER values verbatim); only a bare key gets the prefix. + const uri = /^[a-z][a-z0-9+.-]*:/i.test(memberId) ? memberId : `urn:uuid:${memberId}`; + lines.push(`MEMBER:${uri}`); } } } if (contact.media) { + // Blob-only entries (Stalwart 1.0) are turned into data: URIs by the + // caller before export (inlineContactMediaForExport); there is nothing + // to write for them here. for (const media of Object.values(contact.media)) { - if (media.uri) { + if (media?.uri) { const prop = media.kind === 'logo' ? 'LOGO' : media.kind === 'sound' ? 'SOUND' : 'PHOTO'; if (media.uri.startsWith('data:')) { - const match = media.uri.match(/^data:([^;]+);base64,(.+)$/); - if (match) { - lines.push(`${prop};ENCODING=b;TYPE=${match[1]}:${match[2]}`); + // Stalwart writes photos without a media type as `data:;base64,` + // (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in. + const parsed = parseBase64DataUri(media.uri); + if (parsed?.data) { + const type = parsed.type || media.mediaType; + lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ''}:${parsed.data}`); } } else { const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : ''; diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index b1fcdfca..86c76f26 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { - View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share, + View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Share, Animated, Dimensions, Easing, Modal, } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; @@ -19,13 +19,15 @@ import { openExternalUrl } from '../lib/open-url'; import type { ContactCard } from '../api/types'; import { useContactsStore } from '../stores/contacts-store'; import { - getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri, + getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef, getPrimaryOrg, formatPartialDate, formatAddress, getContactKeywords, isGroup, getCompletedYears, getPhoneFeatures, getActiveContexts, getPrimaryNickname, } from '../lib/contact-utils'; +import { inlineContactMedia, inlineContactMediaForExport } from '../lib/contact-media'; import { contactToVCard } from '../lib/vcard'; import SenderAvatar from '../components/SenderAvatar'; +import ContactPhoto from '../components/contacts/ContactPhoto'; import Dialog from '../components/Dialog'; import { ContactActivity } from '../components/contacts/ContactActivity'; import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet'; @@ -176,7 +178,7 @@ export default function ContactDetailScreen() { : getPrimaryNickname(contact); const email = getContactPrimaryEmail(contact); const phone = contact.phones ? Object.values(contact.phones)[0]?.number : ''; - const photoUri = getContactPhotoUri(contact); + const photo = getContactPhotoRef(contact); const org = getPrimaryOrg(contact); const emails = contact.emails ? Object.entries(contact.emails).map(([id, e]) => ({ id, ...e })) : []; @@ -258,7 +260,9 @@ export default function ContactDetailScreen() { }; const doShare = async () => { - const vcard = contactToVCard(contact); + // A photo stored as a blob (Stalwart 1.0) is downloaded into the vCard. + const [exported] = await inlineContactMediaForExport([contact]); + const vcard = contactToVCard(exported); try { const safe = name.replace(/[^a-z0-9-_]+/gi, '_').slice(0, 40) || 'contact'; const path = `${FileSystem.cacheDirectory}${safe}.vcf`; @@ -307,6 +311,10 @@ export default function ContactDetailScreen() { name: rest.name ? { ...rest.name, full: baseName } : { full: baseName }, }; try { + // A blobId names the bytes embedded in the source card (Stalwart 1.0); + // the copy gets the bytes themselves. + const media = await inlineContactMedia(contact, 'keep'); + if (media) draft.media = media; const created = await createContact(draft, targetBookId); navigation.replace('ContactDetail', { contactId: created.id }); } catch (err) { @@ -392,10 +400,14 @@ export default function ContactDetailScreen() { - {photoUri ? ( - + {photo ? ( + } + /> ) : ( - + )} {name} {!!nickname && “{nickname}”} diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index 1d3cec1b..54ce4b6e 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, ScrollView, TextInput, Alert, - KeyboardAvoidingView, Platform, Modal, Image, + KeyboardAvoidingView, Platform, Modal, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; @@ -14,19 +14,17 @@ import { Camera, X as XIcon, Globe, Heart, UserCircle, Plus, ChevronDown, ChevronRight, Book, Users, } from 'lucide-react-native'; import type { RootStackParamList } from '../navigation/types'; -import type { - ContactCard, ContactEmail, ContactPhone, ContactAddress, ContactOrganization, - ContactAnniversary, ContactNote, ContactMedia, ContactOnlineService, - ContactPersonalInfo, ContactNickname, -} from '../api/types'; +import type { ContactCard } from '../api/types'; import { useContactsStore, selectGroupMembers } from '../stores/contacts-store'; import { - getContactKeywords, getContactDisplayName, getContactPrimaryEmail, - normalizeContactPhotoUri, partialDateToString, stringToPartialDate, - deriveFullName, getCustomFullName, + getContactKeywords, getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef, + stringToPartialDate, type ContactPhotoRef, } from '../lib/contact-utils'; +import { blankForm, contactToForm, formToPatch, type FormState } from '../lib/contact-form'; +import { mediaForFormSave } from '../lib/contact-media'; import { splitMailbox } from '../lib/rfc5322-mailbox'; import Dialog from '../components/Dialog'; +import ContactPhoto from '../components/contacts/ContactPhoto'; import ContactPickerSheet from '../components/contacts/ContactPickerSheet'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -35,454 +33,6 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; type Nav = NativeStackNavigationProp; type Route = RouteProp; -interface EmailDraft { address: string; context: string } -interface PhoneDraft { number: string; context: string; feature: string } -interface AddressDraft { - street: string; - locality: string; - region: string; - postcode: string; - country: string; - context: string; -} -interface OrgDraft { name: string; department: string; jobTitle: string; role: string } -interface AnniversaryDraft { kind: string; date: string } -interface OnlineDraft { uri: string; service: string; label: string } -interface PersonalInfoDraft { kind: string; level: string; value: string } -interface NoteDraft { note: string } - -interface FormState { - isOrg: boolean; - prefix: string; - given: string; - middle: string; - surname: string; - suffix: string; - full: string; - nicknames: string[]; - emails: EmailDraft[]; - phones: PhoneDraft[]; - addresses: AddressDraft[]; - orgs: OrgDraft[]; - anniversaries: AnniversaryDraft[]; - online: OnlineDraft[]; - personalInfo: PersonalInfoDraft[]; - notes: NoteDraft[]; - keywords: string[]; - grammaticalGender: string; - pronouns: string; - calendarUri: string; - schedulingUri: string; - freeBusyUri: string; - addressBookId: string; - photoUri: string; - photoMediaType: string; - /** Group members (contact ids from the store), only used when editing a group. */ - members: string[]; -} - -function blankForm(): FormState { - return { - isOrg: false, - prefix: '', - given: '', - middle: '', - surname: '', - suffix: '', - full: '', - nicknames: [''], - emails: [{ address: '', context: '' }], - phones: [], - addresses: [], - orgs: [], - anniversaries: [], - online: [], - personalInfo: [], - notes: [], - keywords: [], - grammaticalGender: '', - pronouns: '', - calendarUri: '', - schedulingUri: '', - freeBusyUri: '', - addressBookId: '', - photoUri: '', - photoMediaType: '', - members: [], - }; -} - -function findNameComponent(contact: ContactCard | undefined, ...kinds: string[]): string { - if (!contact?.name?.components) return ''; - const found = contact.name.components.find((c) => kinds.includes(c.kind as string)); - return found?.value || ''; -} - -function addressToFlat(a: ContactAddress): AddressDraft { - if (a.components && a.components.length > 0) { - const collect = (kind: string) => a.components!.filter((c) => c.kind === kind).map((c) => c.value).join(' '); - const number = collect('number'); - const name = collect('name'); - return { - street: [number, name].filter(Boolean).join(' ') || a.street || '', - locality: collect('locality') || a.locality || '', - region: collect('region') || a.region || '', - postcode: collect('postcode') || a.postcode || '', - country: collect('country') || a.country || '', - context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', - }; - } - return { - street: a.street || '', - locality: a.locality || '', - region: a.region || '', - postcode: a.postcode || '', - country: a.country || '', - context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', - }; -} - -function contactToForm(contact: ContactCard, memberIds: string[]): FormState { - const prefix = findNameComponent(contact, 'title', 'prefix'); - const given = findNameComponent(contact, 'given'); - const middle = findNameComponent(contact, 'given2', 'additional', 'middle'); - const surname = findNameComponent(contact, 'surname'); - const suffix = findNameComponent(contact, 'generation', 'suffix'); - - const nicknames = contact.nicknames - ? Object.values(contact.nicknames).map((n) => n.name || '').filter(Boolean) - : []; - - const emails = contact.emails ? Object.values(contact.emails).map((e) => ({ - address: e.address, - context: e.contexts?.work ? 'work' : e.contexts?.private ? 'private' : '', - })) : []; - - const phones = contact.phones ? Object.values(contact.phones).map((p) => ({ - number: p.number, - context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '', - feature: - p.features?.cell ? 'cell' - : p.features?.fax ? 'fax' - : p.features?.pager ? 'pager' - : p.features?.video ? 'video' - : p.features?.text ? 'text' - : p.features?.voice ? 'voice' - : '', - })) : []; - - const addresses = contact.addresses ? Object.values(contact.addresses).map(addressToFlat) : []; - - // Pair organization and titles into a single "work" record per index. - const rawOrgs = contact.organizations ? Object.values(contact.organizations) : []; - const titles = contact.titles ? Object.values(contact.titles) : []; - const orgs: OrgDraft[] = []; - const maxLen = Math.max(rawOrgs.length, titles.length); - for (let i = 0; i < maxLen; i++) { - const o = rawOrgs[i]; - const t = titles[i]; - orgs.push({ - name: o?.name || '', - department: o?.units?.[0]?.name || '', - jobTitle: t?.kind !== 'role' ? t?.name || '' : '', - role: t?.kind === 'role' ? t.name : '', - }); - } - - const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries).map((a) => ({ - kind: a.kind || 'birth', - date: partialDateToString(a.date), - })) : []; - - const online = contact.onlineServices ? Object.values(contact.onlineServices).map((s) => ({ - uri: s.uri || '', - service: s.service || '', - label: s.label || '', - })) : []; - - const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo).map((pi) => ({ - kind: pi.kind || 'hobby', - level: pi.level || '', - value: pi.value || '', - })) : []; - - const notes = contact.notes ? Object.values(contact.notes).map((n) => ({ note: n.note })) : []; - - const keywords = contact.keywords - ? Object.keys(contact.keywords).filter((k) => contact.keywords![k]) - : []; - - const addressBookId = Object.keys(contact.addressBookIds || {}) - .find((id) => contact.addressBookIds[id]) || ''; - - const photoEntry = contact.media - ? Object.values(contact.media).find((m) => m.kind === 'photo') - : undefined; - // Stalwart may hand back `data:base64,…` without a media type (#307). - const photoUri = photoEntry?.uri ? normalizeContactPhotoUri(photoEntry.uri, photoEntry.mediaType) : ''; - const photoMediaType = photoEntry?.mediaType || (photoUri.startsWith('data:image/jpeg') ? 'image/jpeg' : ''); - - const grammaticalGender = contact.speakToAs?.grammaticalGender || ''; - const pronouns = contact.speakToAs?.pronouns - ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || '' - : ''; - - // A card may describe an organization instead of a person (RFC 9553 kind - // "org"). Older cards predate the explicit kind, so fall back to "has an org - // name but no personal name". - const isOrg = contact.kind - ? contact.kind === 'org' - : !(given || surname) && !!rawOrgs[0]?.name; - // Only a display name of its own goes in the field; a derived one is - // derived again on save, so it can't go stale when the name changes. - const full = getCustomFullName(contact, isOrg ? rawOrgs[0]?.name : undefined); - - return { - isOrg, prefix, given, middle, surname, suffix, full, - nicknames: nicknames.length > 0 ? nicknames : [''], - emails: emails.length > 0 ? emails : [{ address: '', context: '' }], - phones, addresses, orgs, anniversaries, online, - personalInfo, notes, keywords, addressBookId, photoUri, photoMediaType, - grammaticalGender, pronouns, - calendarUri: contact.calendarUri || '', - schedulingUri: contact.schedulingUri || '', - freeBusyUri: contact.freeBusyUri || '', - members: memberIds, - }; -} - -/** Group-membership key for a card (RFC 9553 members are keyed by UID). */ -function memberKey(contact: ContactCard): string { - return contact.uid || contact.originalId || contact.id; -} - -function memberKeyMatches(key: string, contact: ContactCard): boolean { - const bare = key.startsWith('urn:uuid:') ? key.slice(9) : key; - const bareUid = contact.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact.uid; - return key === contact.id || bare === contact.id - || (!!contact.originalId && (key === contact.originalId || bare === contact.originalId)) - || (!!contact.uid && (key === contact.uid || bare === bareUid)); -} - -/** - * Build the JMAP patch. In edit mode every collection the form owns that ended - * up empty is sent as `null` so the server clears it - omitting the key would - * mean "unchanged" and the removed phone/address/note would come right back. - */ -function formToPatch( - form: FormState, - existing: ContactCard | undefined, - asGroup: boolean, - allContacts: ContactCard[], -): Partial { - const isEdit = !!existing; - const orgName = form.orgs[0]?.name.trim() || ''; - - // Organization cards carry no personal name components; the org name goes - // into `name.full` below. A group name lives in `given`. - const components: Array<{ kind: string; value: string }> = []; - if (!form.isOrg) { - if (form.prefix.trim()) components.push({ kind: 'title', value: form.prefix.trim() }); - if (form.given.trim()) components.push({ kind: 'given', value: form.given.trim() }); - if (form.middle.trim()) components.push({ kind: 'given2', value: form.middle.trim() }); - if (form.surname.trim()) components.push({ kind: 'surname', value: form.surname.trim() }); - if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() }); - } - - // Always send `name.full`: the vCard FN is built from it and is mandatory - // (#430). Without personal name components, carry the organization name - // so servers and other clients have something to display. - const full = form.full.trim() || (form.isOrg ? orgName : deriveFullName(components)); - const name: ContactCard['name'] | undefined = - components.length > 0 || full - ? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) } - : undefined; - - const nicknames: Record = {}; - form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => { - nicknames[`n${i}`] = { name: n }; - }); - - const emails: Record = {}; - form.emails.filter((e) => e.address.trim()).forEach((e, i) => { - emails[`e${i + 1}`] = { - address: e.address.trim(), - ...(e.context ? { contexts: { [e.context]: true } } : {}), - }; - }); - - const phones: Record = {}; - form.phones.filter((p) => p.number.trim()).forEach((p, i) => { - phones[`p${i + 1}`] = { - number: p.number.trim(), - ...(p.context ? { contexts: { [p.context]: true } } : {}), - ...(p.feature ? { features: { [p.feature]: true } } : {}), - }; - }); - - const addresses: Record = {}; - form.addresses - .filter((a) => a.street.trim() || a.locality.trim() || a.country.trim() || a.region.trim() || a.postcode.trim()) - .forEach((a, i) => { - const comps: Array<{ kind: string; value: string }> = []; - if (a.street.trim()) comps.push({ kind: 'name', value: a.street.trim() }); - if (a.locality.trim()) comps.push({ kind: 'locality', value: a.locality.trim() }); - if (a.region.trim()) comps.push({ kind: 'region', value: a.region.trim() }); - if (a.postcode.trim()) comps.push({ kind: 'postcode', value: a.postcode.trim() }); - if (a.country.trim()) comps.push({ kind: 'country', value: a.country.trim() }); - addresses[`a${i + 1}`] = { - components: comps, - isOrdered: true, - defaultSeparator: ', ', - ...(a.context ? { contexts: { [a.context]: true } } : {}), - }; - }); - - const organizations: Record = {}; - const titles: Record = {}; - form.orgs.forEach((o, i) => { - if (o.name.trim() || o.department.trim()) { - const units = o.department.trim() ? [{ name: o.department.trim() }] : undefined; - organizations[`o${i + 1}`] = { - ...(o.name.trim() ? { name: o.name.trim() } : {}), - ...(units ? { units } : {}), - }; - } - if (o.jobTitle.trim()) { - titles[`t${i + 1}`] = { name: o.jobTitle.trim(), kind: 'title' }; - } - if (o.role.trim()) { - titles[`r${i + 1}`] = { name: o.role.trim(), kind: 'role' }; - } - }); - - const anniversaries: Record = {}; - form.anniversaries.forEach((a, i) => { - const date = stringToPartialDate(a.date); - if (!date) return; - anniversaries[`an${i + 1}`] = { kind: a.kind as ContactAnniversary['kind'], date }; - }); - - const onlineServices: Record = {}; - form.online.filter((s) => s.uri.trim()).forEach((s, i) => { - onlineServices[`os${i + 1}`] = { - uri: s.uri.trim(), - ...(s.service.trim() ? { service: s.service.trim() } : {}), - ...(s.label.trim() ? { label: s.label.trim() } : {}), - }; - }); - - const personalInfo: Record = {}; - form.personalInfo.filter((p) => p.value.trim()).forEach((p, i) => { - personalInfo[`pi${i + 1}`] = { - kind: p.kind as ContactPersonalInfo['kind'], - value: p.value.trim(), - ...(p.level ? { level: p.level as 'high' | 'medium' | 'low' } : {}), - }; - }); - - const notes: Record = {}; - form.notes.forEach((n, i) => { - if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() }; - }); - - const keywords: Record = {}; - form.keywords.forEach((k) => { - if (k.trim()) keywords[k.trim()] = true; - }); - - // Media: keep every non-photo entry (logo, sound) the card already has and - // write the photo back under its original key. - const media: Record = {}; - let photoKey = 'photo'; - if (existing?.media) { - for (const [key, m] of Object.entries(existing.media)) { - if (m.kind === 'photo') photoKey = key; - else media[key] = m; - } - } - if (form.photoUri.trim()) { - media[photoKey] = { - kind: 'photo', - uri: form.photoUri.trim(), - ...(form.photoMediaType ? { mediaType: form.photoMediaType } : {}), - }; - } - - const speakToAs = - form.grammaticalGender || form.pronouns.trim() - ? { - ...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}), - ...(form.pronouns.trim() - ? { pronouns: { p0: { pronouns: form.pronouns.trim() } } } - : {}), - } - : undefined; - - // Collections: value when non-empty; `null` on edit when the card had one - // before (clears it server-side); omitted otherwise. - const collection = (key: keyof ContactCard, value: Record): Record => { - if (Object.keys(value).length > 0) return { [key]: value }; - if (isEdit && existing?.[key] !== undefined) return { [key]: null }; - return {}; - }; - const scalar = (key: keyof ContactCard, value: string): Record => { - if (value) return { [key]: value }; - if (isEdit && existing?.[key] !== undefined) return { [key]: null }; - return {}; - }; - - // Only send `kind` when this form owns the answer: switching a card between - // person and organization. Leave other kinds (group, location, ...) untouched. - const kind: Partial = asGroup - ? { kind: 'group' } - : form.isOrg - ? { kind: 'org' } - : existing?.kind === 'org' ? { kind: 'individual' } : {}; - - const patch: Record = { - ...kind, - ...(name ? { name } : isEdit && existing?.name ? { name: null } : {}), - ...collection('nicknames', nicknames), - ...collection('emails', emails), - ...collection('phones', phones), - ...collection('addresses', addresses), - ...collection('organizations', organizations), - ...collection('titles', titles), - ...collection('anniversaries', anniversaries), - ...collection('onlineServices', onlineServices), - ...collection('personalInfo', personalInfo), - ...collection('notes', notes), - ...collection('keywords', keywords), - ...(speakToAs ? { speakToAs } : isEdit && existing?.speakToAs ? { speakToAs: null } : {}), - ...scalar('calendarUri', form.calendarUri.trim()), - ...scalar('schedulingUri', form.schedulingUri.trim()), - ...scalar('freeBusyUri', form.freeBusyUri.trim()), - ...collection('media', media), - }; - - if (asGroup) { - // Start from the stored map so members we cannot resolve locally survive; - // drop the resolved ones that were deselected and add the new picks. - const members: Record = { ...(existing?.members || {}) }; - const selected = form.members - .map((id) => allContacts.find((c) => c.id === id)) - .filter((c): c is ContactCard => !!c); - for (const key of Object.keys(members)) { - const owner = allContacts.find((c) => memberKeyMatches(key, c)); - if (owner && !selected.some((s) => s.id === owner.id)) delete members[key]; - } - for (const contact of selected) { - if (!Object.keys(members).some((key) => memberKeyMatches(key, contact))) { - members[memberKey(contact)] = true; - } - } - patch.members = members; - } - - return patch as Partial; -} - type Option = { value: string; label: string }; function formOptions(t: TranslateFn) { @@ -701,6 +251,7 @@ export default function ContactFormScreen() { const allContacts = useContactsStore((s) => s.contacts); const createContact = useContactsStore((s) => s.createContact); const updateContact = useContactsStore((s) => s.updateContact); + const fetchContactMedia = useContactsStore((s) => s.fetchContactMedia); const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId); const existing = React.useMemo( () => (contactId ? allContacts.find((c) => c.id === contactId) : undefined), @@ -831,11 +382,26 @@ export default function ContactFormScreen() { setSaving(true); try { const patch = formToPatch(form, existing, !!asGroup || existing?.kind === 'group', allContacts); + // `media` goes out only when the photo changed, built on the card as the + // server has it now: a blobId from an older copy of the card, say of a + // logo changed elsewhere since, makes Stalwart 1.0 refuse the whole + // update ("The following blobIds do not exist or are not accessible"). + let photoKey: string | undefined; + if (form.photo.kind !== 'unchanged') { + const current = existing ? await fetchContactMedia(existing.id) : null; + const edited = mediaForFormSave(existing?.media, current, form.photo); + if (edited) { + // `null` when no entry is left: it deletes them on the server. + Object.assign(patch, { media: edited.media }); + photoKey = edited.photoKey; + } + } + const options = photoKey ? { photoKey } : undefined; if (isEdit && existing) { - await updateContact(existing.id, patch); + await updateContact(existing.id, patch, options); navigation.goBack(); } else { - const created = await createContact(patch, form.addressBookId); + const created = await createContact(patch, form.addressBookId, options); if (asGroup) navigation.replace('GroupDetail', { groupId: created.id }); else navigation.replace('ContactDetail', { contactId: created.id }); } @@ -879,10 +445,19 @@ export default function ContactFormScreen() { }); if (result.canceled || !result.assets[0]) return; const { uri, mediaType } = await processPickedImage(result.assets[0]); - setDirty(true); - setForm((f) => ({ ...f, photoUri: uri, photoMediaType: mediaType })); + updateForm('photo', { kind: 'new', uri, mediaType }); }; + const previewPhoto: ContactPhotoRef | null = + form.photo.kind === 'new' ? { uri: form.photo.uri } + : form.photo.kind === 'unchanged' && existing ? getContactPhotoRef(existing) ?? null + : null; + const photoPlaceholder = ( + + + + ); + const memberContacts = React.useMemo( () => form.members .map((id) => allContacts.find((c) => c.id === id)) @@ -1125,13 +700,9 @@ export default function ContactFormScreen() { accessibilityRole="button" accessibilityLabel={t('contacts.form.upload_photo', 'Upload photo')} > - {form.photoUri ? ( - - ) : ( - - - - )} + {previewPhoto ? ( + + ) : photoPlaceholder} @@ -1140,12 +711,9 @@ export default function ContactFormScreen() { : t('contacts.create_new', 'New Contact'))} {t('contacts.form.photo_hint_mobile', 'Tap photo to choose an image')} - {form.photoUri ? ( + {previewPhoto ? ( { - updateForm('photoUri', ''); - updateForm('photoMediaType', ''); - }} + onPress={() => updateForm('photo', { kind: 'removed' })} style={styles.removePhotoBtn} hitSlop={8} accessibilityRole="button" diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx index 58467fd3..b08a8ab7 100644 --- a/src/screens/ContactsScreen.tsx +++ b/src/screens/ContactsScreen.tsx @@ -32,6 +32,7 @@ import { } from '../stores/contacts-store'; import { getContactDisplayName, getContactSortName, isGroup, matchesContactSearch } from '../lib/contact-utils'; import { contactsToVCard } from '../lib/vcard'; +import { inlineContactMediaForExport } from '../lib/contact-media'; import { ContactListRow, AddressBookPickerSheet, @@ -237,10 +238,21 @@ export default function ContactsScreen() { }); }; + // Downloading photos stored as blobs (Stalwart 1.0) into the file takes a + // moment; a second tap meanwhile would start the whole export again. + const exportingRef = React.useRef(false); const exportSelected = async () => { const selected = contacts.filter((c) => selection.has(c.id)); - if (selected.length === 0) return; - const vcf = contactsToVCard(selected); + if (selected.length === 0 || exportingRef.current) return; + exportingRef.current = true; + try { + await shareVCard(contactsToVCard(await inlineContactMediaForExport(selected))); + } finally { + exportingRef.current = false; + } + }; + + const shareVCard = async (vcf: string) => { try { const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`; const path = `${FileSystem.cacheDirectory}${filename}`; diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index 9a2ebb96..708937d1 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -14,6 +14,7 @@ vi.mock('../../api/contacts', () => ({ deleteAddressBook: vi.fn(), setDefaultAddressBook: vi.fn(), getContactsInBook: vi.fn(), + getContactMedia: vi.fn(), getContactsAccountId: () => 'acc-1', getContactCapableAccountIds: () => ['acc-1', 'acc-team'], namespaceId: (accountId: string, id: string) => `${accountId}:${id}`, @@ -40,10 +41,21 @@ vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'acc-1', isConnected: true, + serverUrl: 'https://mail.example', + getAccountCapability: vi.fn(), + fetchBlobArrayBuffer: vi.fn(), }, })); +vi.mock('../../api/blob', () => ({ + uploadBytes: vi.fn(), + getBlobsAsBase64: vi.fn(), +})); + import * as contactsApi from '../../api/contacts'; +import * as blobApi from '../../api/blob'; +import { jmapClient } from '../../api/jmap-client'; +import { resetContactBlobMediaRefusals } from '../../lib/contact-media'; import * as recentApi from '../../api/recent-recipients'; import { useContactsStore, @@ -256,6 +268,93 @@ describe('contacts-store', () => { }); }); + describe('contact photos', () => { + const PHOTO_URI = 'data:image/jpeg;base64,/9j/4AAQ'; + const mockCapability = jmapClient.getAccountCapability as ReturnType; + const mockUpload = blobApi.uploadBytes as ReturnType; + const mockFetchBlob = jmapClient.fetchBlobArrayBuffer as ReturnType; + const mockGetMedia = contactsApi.getContactMedia as ReturnType; + const blobCapability = (version: '1.0' | '0.16') => ({ + supportedTypeNames: version === '1.0' + ? ['Email', 'Thread', 'SieveScript', 'CalendarEvent', 'ContactCard'] + : ['Email', 'Thread', 'SieveScript'], + }); + const newPhoto = { media: { photo: { kind: 'photo', uri: PHOTO_URI, mediaType: 'image/jpeg' } } }; + + beforeEach(() => { + resetContactBlobMediaRefusals(); + }); + + it('uploads a newly picked photo and sends its blobId on Stalwart 1.0', async () => { + mockCapability.mockReturnValue(blobCapability('1.0')); + mockUpload.mockResolvedValue({ blobId: 'eavlk-up', type: 'image/jpeg', size: 4 }); + useContactsStore.setState({ contacts: [card('c1')] }); + mockUpdateContact.mockResolvedValue(undefined); + + await useContactsStore.getState().updateContact('c1', newPhoto, { photoKey: 'photo' }); + + expect(mockUpload.mock.calls[0][2]).toBe('acc-1'); + expect(mockUpdateContact).toHaveBeenCalledWith( + 'c1', { media: { photo: { kind: 'photo', blobId: 'eavlk-up', mediaType: 'image/jpeg' } } }, undefined, + ); + // The list shows the picked photo right away. + expect(useContactsStore.getState().contacts[0].media?.photo?.uri).toBe(PHOTO_URI); + }); + + it('sends the data: URI on 0.16, also for a new contact', async () => { + mockCapability.mockReturnValue(blobCapability('0.16')); + useContactsStore.setState({ contacts: [card('c1')], addressBooks: [{ id: 'ab-1', name: 'P' }] }); + mockUpdateContact.mockResolvedValue(undefined); + mockCreateContact.mockResolvedValue({ id: 'c2', addressBookIds: { 'ab-1': true } }); + + await useContactsStore.getState().updateContact('c1', newPhoto, { photoKey: 'photo' }); + await useContactsStore.getState().createContact({ name: { full: 'N' }, ...newPhoto }, 'ab-1', { photoKey: 'photo' }); + + expect(mockUpload).not.toHaveBeenCalled(); + expect(mockUpdateContact).toHaveBeenCalledWith('c1', newPhoto, undefined); + expect(mockCreateContact).toHaveBeenCalledWith({ name: { full: 'N' }, ...newPhoto }, 'ab-1', undefined); + }); + + it('reads the media of the card as the server has it now', async () => { + useContactsStore.setState({ + contacts: [card('acc-team:c1', { originalId: 'c1', accountId: 'acc-team', isShared: true })], + }); + mockGetMedia.mockResolvedValueOnce({ photo: { blobId: 'b', kind: 'photo' } }); + expect(await useContactsStore.getState().fetchContactMedia('acc-team:c1')) + .toEqual({ media: { photo: { blobId: 'b', kind: 'photo' } } }); + expect(mockGetMedia).toHaveBeenCalledWith('c1', 'acc-team'); + + mockGetMedia.mockResolvedValueOnce(null); + expect(await useContactsStore.getState().fetchContactMedia('acc-team:c1')).toEqual({ media: undefined }); + // Gone, or the request failed: the form falls back to its own copy. + mockGetMedia.mockResolvedValueOnce(undefined); + expect(await useContactsStore.getState().fetchContactMedia('acc-team:c1')).toBeNull(); + mockGetMedia.mockRejectedValueOnce(new Error('offline')); + expect(await useContactsStore.getState().fetchContactMedia('acc-team:c1')).toBeNull(); + }); + + it('sends the bytes of a blob photo when moving a card to another account (Stalwart 1.0)', async () => { + useContactsStore.setState({ + addressBooks: [ + { id: 'ab-1', name: 'P' }, + { id: 'acc-team:ab', originalId: 'ab', name: 'Team', accountId: 'acc-team', accountName: 'Team', isShared: true }, + ], + contacts: [card('c1', { media: { photo: { blobId: 'b-photo', kind: 'photo', mediaType: 'image/jpeg' } } })], + }); + mockFetchBlob.mockResolvedValue(new Uint8Array([0xff, 0xd8]).buffer); + mockCreateContact.mockResolvedValue({ id: 'c9', addressBookIds: { ab: true } }); + mockDeleteContacts.mockResolvedValue(['c1']); + + await useContactsStore.getState().moveContactsToAddressBook(['c1'], 'acc-team:ab'); + + expect(mockFetchBlob).toHaveBeenCalledWith('b-photo', 'photo', 'image/jpeg', 'acc-1'); + expect(mockCreateContact.mock.calls[0][0].media).toEqual({ + photo: { kind: 'photo', mediaType: 'image/jpeg', uri: 'data:image/jpeg;base64,/9g=' }, + }); + expect(mockDeleteContacts).toHaveBeenCalledWith(['c1'], undefined); + }); + }); + describe('deleteContact', () => { it('should remove from list and clean group members', async () => { useContactsStore.setState({ diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index b4a588fb..5c09178d 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -19,9 +19,11 @@ import { getContactsInBook, getContactsAccountId, getContactCapableAccountIds, + getContactMedia as apiGetContactMedia, namespaceId, stripClientFields, } from '../api/contacts'; +import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '../lib/contact-media'; import { queryRecentRecipients, searchSentRecipients, type RecentRecipient } from '../api/recent-recipients'; import { jmapClient } from '../api/jmap-client'; import { @@ -85,8 +87,15 @@ export interface ContactsState { refresh: () => Promise; handleStateChange: (change: StateChange) => Promise; - createContact: (contact: Partial, addressBookId: string) => Promise; - updateContact: (id: string, changes: Partial) => Promise; + /** `options.photoKey` names the `media` entry holding a newly picked photo. */ + createContact: ( + contact: Partial, + addressBookId: string, + options?: ContactSaveOptions, + ) => Promise; + updateContact: (id: string, changes: Partial, options?: ContactSaveOptions) => Promise; + /** The card's `media` as the server has it now, or null when it can't be read. */ + fetchContactMedia: (id: string) => Promise | null>; deleteContact: (id: string) => Promise; bulkDelete: (ids: string[]) => Promise; importContacts: ( @@ -399,17 +408,27 @@ export const useContactsStore = create()( } }, - createContact: async (contact, addressBookId) => { + createContact: async (contact, addressBookId, options) => { const { originalId, accountId, book } = bookTarget(addressBookId); - const created = tagCreated(await apiCreateContact(contact, originalId, accountId), book); + const write = (card: Partial) => apiCreateContact(card, originalId, accountId); + const saved = options?.photoKey + ? await writeContactWithNewPhoto(accountId, contact, options.photoKey, write) + : await write(contact); + const created = tagCreated(saved, book); set({ contacts: [...get().contacts, created] }); return created; }, - updateContact: async (id, changes) => { + updateContact: async (id, changes, options) => { const contact = get().contacts.find((c) => c.id === id); const { originalId, accountId } = contactTarget(id); - await apiUpdateContact(originalId, cleanPatch(contact, changes), accountId); + const patch = cleanPatch(contact, changes); + const write = (card: Partial) => apiUpdateContact(originalId, card, accountId); + if (options?.photoKey) { + await writeContactWithNewPhoto(accountId, patch, options.photoKey, write); + } else { + await write(patch); + } set({ contacts: get().contacts.map((c) => { if (c.id !== id) return c; @@ -424,6 +443,17 @@ export const useContactsStore = create()( }); }, + fetchContactMedia: async (id) => { + const { originalId, accountId } = contactTarget(id); + try { + const media = await apiGetContactMedia(originalId, accountId); + return media === undefined ? null : { media: media ?? undefined }; + } catch (err) { + console.warn('[contacts-store] reading the card media failed', err); + return null; + } + }, + deleteContact: async (id) => { const { originalId, accountId } = contactTarget(id); await apiDeleteContacts([originalId], accountId); @@ -539,6 +569,13 @@ export const useContactsStore = create()( } else { // Cross-account: create in the target account, then delete the source. const { id: _id, addressBookIds: _ab, uid: _uid, created: _c, updated: _u, ...data } = stripClientFields(contact); + // A blobId names a photo in the source account and need not + // resolve for the target one; send the bytes. If a download + // fails the entry goes as it is: the create then either resolves + // it or fails, and the source card is only deleted after a + // successful create. + const media = await inlineContactMedia(contact, 'keep'); + if (media) data.media = media; const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book); await apiDeleteContacts([source.originalId], source.accountId); set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) });