fix: show and keep contact photos in the app on Stalwart 1.0

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.
This commit is contained in:
Linus Rath
2026-09-25 20:32:13 +02:00
parent fe0facfea5
commit f52ef7db7e
28 changed files with 2557 additions and 544 deletions
+73
View File
@@ -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, Record<string, unknown>> }, string]>) => {
const update = calls[0][1].update.c1;
const refuse = (property: string, description: string) =>
({ notUpdated: { c1: { type: 'invalidProperties', properties: [property], description } } });
let answer: Record<string, unknown> = { 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', () => {
+108
View File
@@ -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<string, string>;
/** 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<T>(items: T[], sizeOf: (item: T) => number | undefined, budget: number, maxCount: number): T[][] {
const limit = Math.max(1, Math.floor(maxCount));
const result: T[][] = [];
let batch: T[] = [];
let total = 0;
for (const item of items) {
const size = sizeOf(item) ?? budget;
if (batch.length > 0 && (total + size > budget || batch.length >= limit)) {
result.push(batch);
batch = [];
total = 0;
}
batch.push(item);
total += size;
}
if (batch.length > 0) result.push(batch);
return result;
}
/**
* 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<BlobBatch | null> {
if (!jmapClient.hasCapability(CAPABILITIES.BLOB)) return null;
const account = accountId || jmapClient.accountId;
const ids = [...new Set(blobIds)];
const data = new Map<string, string>();
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<Record<string, unknown>>;
};
let chunks: string[][] = ids.length > 0 ? [ids] : [];
if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) {
const sizes = new Map<string, number | undefined>();
try {
for (const chunk of batched(ids, 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: [] };
}
+43 -4
View File
@@ -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<void> {
const account = accountId || getContactsAccountId();
const patch = { ...stripClientFields(changes) } as Record<string, unknown>;
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<string, SetError> }>(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<Record<string, ContactMedia> | 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<Pick<ContactCard, 'id' | 'media'>> }>(res).list ?? [])
.find((c) => c.id === id);
if (!card) return undefined;
return card.media ?? null;
}
/**
+25
View File
@@ -133,3 +133,28 @@ export function batched<T>(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 ?? [] : [];
}
+10 -1
View File
@@ -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<string, boolean>;
}
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;
+24 -12
View File
@@ -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 (
<View style={containerStyle}>
<Image
source={{ uri: contactPhoto }}
source={photoSource}
style={{ width: size, height: size, borderRadius: size / 2 }}
resizeMode="cover"
onError={() => setPhotoError(true)}
onLoad={onPhotoLoad}
onError={onPhotoError}
/>
</View>
);
+16 -12
View File
@@ -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 ? (
<View style={styles.groupAvatar}>
<Users size={18} color={c.primary} />
</View>
) : (
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} photo={null} />
);
return (
<Pressable
@@ -61,15 +71,9 @@ export default function ContactListRow({
</View>
)}
{photoUri ? (
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
) : group ? (
<View style={styles.groupAvatar}>
<Users size={18} color={c.primary} />
</View>
) : (
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
)}
{photo ? (
<ContactPhoto photo={photo} style={styles.avatarImage} fallback={fallbackAvatar} />
) : fallbackAvatar}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{name}</Text>
+29
View File
@@ -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<ImageStyle>;
/**
* 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 (
<Image
source={source}
style={style}
resizeMode="cover"
onLoad={onLoad}
onError={onError}
accessibilityIgnoresInvertColors
/>
);
}
+3 -1
View File
@@ -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);
+44 -6
View File
@@ -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<string, ContactMedia>, extra: Partial<ContactCard> = {}): 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();
});
});
+88
View File
@@ -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' });
});
});
+443
View File
@@ -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<string, string>,
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
capabilities: {} as Record<string, unknown>,
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<typeof import('../../api/blob')>()),
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<typeof vi.fn>;
const mockFetchBlob = jmapClient.fetchBlobArrayBuffer as ReturnType<typeof vi.fn>;
const mockUpload = uploadBytes as ReturnType<typeof vi.fn>;
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<string, Record<string, unknown>> = {}) {
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<string, ContactMedia> = {
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<string, ContactMedia> = {
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<string, ContactMedia> = { 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<string, ContactMedia> = {
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<string, ContactMedia> = { 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<ContactCard> => ({
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<string, ContactMedia>, extra: Partial<ContactCard> = {}): 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<string, number>) {
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();
});
});
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const session = {
primaryAccounts: { 'urn:ietf:params:jmap:contacts': 'f' } as Record<string, string>,
accounts: {},
capabilities: { 'urn:ietf:params:jmap:core': { maxConcurrentRequests: 4 } } as Record<string, unknown>,
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');
});
});
+45
View File
@@ -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> = {}): 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'],
+66
View File
@@ -345,3 +345,69 @@ describe('organization-only cards (issue #701)', () => {
expect(parseVCard(vcf)).toHaveLength(1);
});
});
describe('MEMBER export', () => {
const group = (members: Record<string, boolean>): 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');
});
});
+9 -9
View File
@@ -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<readonly ContactCard[], Map<string, string>>();
const contactPhotoIndexCache = new WeakMap<readonly ContactCard[], Map<string, ContactPhotoRef>>();
export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map<string, string> {
export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map<string, ContactPhotoRef> {
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<stri
export function getContactPhotoForEmail(
contacts: readonly ContactCard[],
email: string | null | undefined,
): string | undefined {
): ContactPhotoRef | undefined {
const key = email?.trim().toLowerCase();
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
}
+441
View File
@@ -0,0 +1,441 @@
// The contact form's state and the ContactCard patch it saves. Kept out of
// ContactFormScreen so the save path can be tested without rendering it.
import type {
ContactCard, ContactEmail, ContactPhone, ContactAddress, ContactOrganization,
ContactAnniversary, ContactNote, ContactOnlineService, ContactPersonalInfo, ContactNickname,
} from '../api/types';
import {
partialDateToString, stringToPartialDate, deriveFullName, getCustomFullName,
} from './contact-utils';
import type { PhotoEdit } from './contact-media';
export interface EmailDraft { address: string; context: string }
export interface PhoneDraft { number: string; context: string; feature: string }
export interface AddressDraft {
street: string;
locality: string;
region: string;
postcode: string;
country: string;
context: string;
}
export interface OrgDraft { name: string; department: string; jobTitle: string; role: string }
export interface AnniversaryDraft { kind: string; date: string }
export interface OnlineDraft { uri: string; service: string; label: string }
export interface PersonalInfoDraft { kind: string; level: string; value: string }
export interface NoteDraft { note: string }
export 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;
/**
* What the user did to the photo. The stored photo may be a data: URI
* (Stalwart 0.16) or a blob (1.0); the form only ever writes the photo the
* user picks, never the one it shows.
*/
photo: PhotoEdit;
/** Group members (contact ids from the store), only used when editing a group. */
members: string[];
}
export 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: '',
photo: { kind: 'unchanged' },
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' : '',
};
}
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<ContactCard> {
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<string, ContactNickname> = {};
form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => {
nicknames[`n${i}`] = { name: n };
});
const emails: Record<string, ContactEmail> = {};
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<string, ContactPhone> = {};
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<string, ContactAddress> = {};
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<string, ContactOrganization> = {};
const titles: Record<string, { name: string; kind?: 'title' | 'role' }> = {};
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<string, ContactAnniversary> = {};
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<string, ContactOnlineService> = {};
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<string, ContactPersonalInfo> = {};
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<string, ContactNote> = {};
form.notes.forEach((n, i) => {
if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() };
});
const keywords: Record<string, boolean> = {};
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<string, unknown>): Record<string, unknown> => {
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<string, unknown> => {
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<ContactCard> = asGroup
? { kind: 'group' }
: form.isOrg
? { kind: 'org' }
: existing?.kind === 'org' ? { kind: 'individual' } : {};
const patch: Record<string, unknown> = {
...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<string, boolean> = { ...(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<ContactCard>;
}
+321
View File
@@ -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<string, ContactMedia> | 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<ContactCard, 'isShared' | 'accountId'>): 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, ContactMedia>): 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<string, ContactMedia>,
known: Record<string, ContactMedia>,
shownKey: string | undefined,
edit: Exclude<PhotoEdit, { kind: 'unchanged' }>,
): { media: Record<string, ContactMedia> | null; photoKey?: string } {
if (edit.kind === 'removed') {
const others = Object.fromEntries(Object.entries(base).filter(
([key, entry]) => !(entry?.kind === 'photo' && known[key]?.kind === 'photo'),
));
return { media: Object.keys(others).length > 0 ? others : null };
}
const shown = shownKey ? (base[shownKey]?.kind === 'photo' ? base[shownKey] : known[shownKey]) : undefined;
const photoKey = shownKey && (!base[shownKey] || base[shownKey]?.kind === 'photo') ? shownKey : freeMediaKey(base);
const photo: ContactMedia = {
kind: 'photo',
uri: edit.uri,
mediaType: edit.mediaType,
...(shown?.pref !== undefined ? { pref: shown.pref } : {}),
...(shown?.label !== undefined ? { label: shown.label } : {}),
...(shown?.contexts !== undefined ? { contexts: shown.contexts } : {}),
};
const media: Record<string, ContactMedia> = {};
for (const [key, entry] of Object.entries(base)) media[key] = key === photoKey ? photo : entry;
media[photoKey] = photo;
return { media, photoKey };
}
/**
* 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<string, ContactMedia> | undefined,
current: Pick<ContactCard, 'media'> | null,
edit: PhotoEdit,
): { media: Record<string, ContactMedia> | 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<string>();
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<T>(
accountId: string | undefined,
card: Partial<ContactCard>,
photoKey: string,
write: (card: Partial<ContactCard>) => Promise<T>,
): Promise<T> {
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<string, ContactMedia>,
uriOf: (entry: BlobMedia) => Promise<string | null>,
onFailure: 'drop' | 'keep',
): Promise<Record<string, ContactMedia>> {
const out: Record<string, ContactMedia> = {};
// 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<string | null> {
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<ContactCard, 'media' | 'isShared' | 'accountId'>,
onFailure: 'drop' | 'keep',
): Promise<Record<string, ContactMedia> | 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<ContactCard[]> {
const byAccount = new Map<string, Map<string, BlobMedia>>();
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<string, string>();
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();
}
+214
View File
@@ -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<string, string>;
}
type BlobPhoto = Extract<ContactPhotoRef, { blobId: string }>;
/**
* 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<string>();
const failedUntil = new Map<string, number>();
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<typeof setTimeout> | 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();
}
+47 -6
View File
@@ -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<ContactCard, 'media'>,
): [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
+46
View File
@@ -0,0 +1,46 @@
// base64 data: URIs, as contact photos carry them (webmail lib/data-uri.ts).
//
// Stalwart writes three shapes: `data:<type>;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' };
}
+75
View File
@@ -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<string | null>(null);
const [blobState, setBlobState] = React.useState<{ key?: string; state: ContactPhotoState }>(
() => ({ key, state: key ? initialContactPhotoState(key) : 'waiting' }),
);
const loadRef = React.useRef<ContactPhotoLoad | null>(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 };
}
+16 -5
View File
@@ -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}` : '';
+19 -7
View File
@@ -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() {
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.hero}>
{photoUri ? (
<Image source={{ uri: photoUri }} style={styles.heroPhoto} />
{photo ? (
<ContactPhoto
photo={photo}
style={styles.heroPhoto}
fallback={<SenderAvatar name={name} email={email} size={96} photo={null} />}
/>
) : (
<SenderAvatar name={name} email={email} size={96} />
<SenderAvatar name={name} email={email} size={96} photo={null} />
)}
<Text style={styles.heroName}>{name}</Text>
{!!nickname && <Text style={styles.heroNickname}>“{nickname}”</Text>}
+41 -473
View File
@@ -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<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
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<ContactCard> {
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<string, ContactNickname> = {};
form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => {
nicknames[`n${i}`] = { name: n };
});
const emails: Record<string, ContactEmail> = {};
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<string, ContactPhone> = {};
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<string, ContactAddress> = {};
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<string, ContactOrganization> = {};
const titles: Record<string, { name: string; kind?: 'title' | 'role' }> = {};
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<string, ContactAnniversary> = {};
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<string, ContactOnlineService> = {};
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<string, ContactPersonalInfo> = {};
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<string, ContactNote> = {};
form.notes.forEach((n, i) => {
if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() };
});
const keywords: Record<string, boolean> = {};
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<string, ContactMedia> = {};
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<string, unknown>): Record<string, unknown> => {
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<string, unknown> => {
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<ContactCard> = asGroup
? { kind: 'group' }
: form.isOrg
? { kind: 'org' }
: existing?.kind === 'org' ? { kind: 'individual' } : {};
const patch: Record<string, unknown> = {
...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<string, boolean> = { ...(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<ContactCard>;
}
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 = (
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
<Camera size={28} color={c.textMuted} />
</View>
);
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 ? (
<Image source={{ uri: form.photoUri }} style={styles.photoThumb} />
) : (
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
<Camera size={28} color={c.textMuted} />
</View>
)}
{previewPhoto ? (
<ContactPhoto photo={previewPhoto} style={styles.photoThumb} fallback={photoPlaceholder} />
) : photoPlaceholder}
</Pressable>
<View style={{ flex: 1 }}>
<Text style={styles.previewName} numberOfLines={1}>
@@ -1140,12 +711,9 @@ export default function ContactFormScreen() {
: t('contacts.create_new', 'New Contact'))}
</Text>
<Text style={styles.previewHint}>{t('contacts.form.photo_hint_mobile', 'Tap photo to choose an image')}</Text>
{form.photoUri ? (
{previewPhoto ? (
<Pressable
onPress={() => {
updateForm('photoUri', '');
updateForm('photoMediaType', '');
}}
onPress={() => updateForm('photo', { kind: 'removed' })}
style={styles.removePhotoBtn}
hitSlop={8}
accessibilityRole="button"
+14 -2
View File
@@ -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}`;
@@ -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<typeof vi.fn>;
const mockUpload = blobApi.uploadBytes as ReturnType<typeof vi.fn>;
const mockFetchBlob = jmapClient.fetchBlobArrayBuffer as ReturnType<typeof vi.fn>;
const mockGetMedia = contactsApi.getContactMedia as ReturnType<typeof vi.fn>;
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({
+43 -6
View File
@@ -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<void>;
handleStateChange: (change: StateChange) => Promise<void>;
createContact: (contact: Partial<ContactCard>, addressBookId: string) => Promise<ContactCard>;
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
/** `options.photoKey` names the `media` entry holding a newly picked photo. */
createContact: (
contact: Partial<ContactCard>,
addressBookId: string,
options?: ContactSaveOptions,
) => Promise<ContactCard>;
updateContact: (id: string, changes: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
/** The card's `media` as the server has it now, or null when it can't be read. */
fetchContactMedia: (id: string) => Promise<Pick<ContactCard, 'media'> | null>;
deleteContact: (id: string) => Promise<void>;
bulkDelete: (ids: string[]) => Promise<void>;
importContacts: (
@@ -399,17 +408,27 @@ export const useContactsStore = create<ContactsState>()(
}
},
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<ContactCard>) => 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<ContactCard>) => 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<ContactsState>()(
});
},
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<ContactsState>()(
} 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)) });