mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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
@@ -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}` : '';
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
Reference in New Issue
Block a user