diff --git a/package-lock.json b/package-lock.json index 7ef7f253..6a9e95bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", + "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", "expo-localization": "~17.0.8", @@ -5287,6 +5288,18 @@ "expo": "*" } }, + "node_modules/expo-image-manipulator": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz", + "integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-picker": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index 4833d568..2abc38ea 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -8,19 +8,25 @@ import { useNavigation, useRoute, type RouteProp } from '@react-navigation/nativ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; +import * as ImageManipulator from 'expo-image-manipulator'; import { ArrowLeft, Mail, Phone, Building, MapPin, Cake, Tag, FileText, User, Check, Calendar, - Camera, X as XIcon, Globe, Heart, UserCircle, Plus, ChevronDown, ChevronRight, Book, + 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, PartialDate, ContactOnlineService, + ContactAnniversary, ContactNote, ContactMedia, ContactOnlineService, ContactPersonalInfo, ContactNickname, } from '../api/types'; -import { useContactsStore } from '../stores/contacts-store'; -import { getContactKeywords, getContactDisplayName } from '../lib/contact-utils'; +import { useContactsStore, selectGroupMembers } from '../stores/contacts-store'; +import { + getContactKeywords, getContactDisplayName, getContactPrimaryEmail, + normalizeContactPhotoUri, partialDateToString, stringToPartialDate, +} from '../lib/contact-utils'; +import { splitMailbox } from '../lib/rfc5322-mailbox'; import Dialog from '../components/Dialog'; +import ContactPickerSheet from '../components/contacts/ContactPickerSheet'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -39,18 +45,19 @@ interface AddressDraft { } interface OrgDraft { name: string; department: string; jobTitle: string; role: string } interface AnniversaryDraft { kind: string; date: string } -interface OnlineDraft { uri: string; service: 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; - nickname: string; + nicknames: string[]; emails: EmailDraft[]; phones: PhoneDraft[]; addresses: AddressDraft[]; @@ -68,17 +75,20 @@ interface FormState { 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: '', - nickname: '', + nicknames: [''], emails: [{ address: '', context: '' }], phones: [], addresses: [], @@ -96,37 +106,10 @@ function blankForm(): FormState { addressBookId: '', photoUri: '', photoMediaType: '', + members: [], }; } -function partialDateToString(d: ContactAnniversary['date']): string { - if (typeof d === 'string') return d; - if (typeof d === 'object' && d) { - if ('@type' in d && d['@type'] === 'Timestamp') return d.utc.split('T')[0]; - const pd = d as PartialDate; - const yr = pd.year ? String(pd.year).padStart(4, '0') : ''; - const mo = pd.month ? String(pd.month).padStart(2, '0') : ''; - const da = pd.day ? String(pd.day).padStart(2, '0') : ''; - if (yr) return `${yr}-${mo || '01'}-${da || '01'}`; - if (mo || da) return `--${mo}-${da}`; - } - return ''; -} - -function stringToAnniversaryDate(s: string): string | PartialDate | null { - const trimmed = s.trim(); - if (!trimmed) return null; - if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { - const [y, m, d] = trimmed.split('-').map((n) => parseInt(n, 10)); - return { year: y, month: m, day: d }; - } - if (/^--\d{2}-\d{2}$/.test(trimmed)) { - const [, , mo, da] = trimmed.split('-'); - return { month: parseInt(mo, 10), day: parseInt(da, 10) }; - } - return trimmed; -} - 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)); @@ -157,7 +140,7 @@ function addressToFlat(a: ContactAddress): AddressDraft { }; } -function contactToForm(contact: ContactCard): FormState { +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'); @@ -165,9 +148,9 @@ function contactToForm(contact: ContactCard): FormState { const suffix = findNameComponent(contact, 'generation', 'suffix'); const full = contact.name?.full || ''; - const nickname = contact.nicknames - ? Object.values(contact.nicknames)[0]?.name || '' - : ''; + 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, @@ -179,12 +162,12 @@ function contactToForm(contact: ContactCard): FormState { 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' - : '', + : 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) : []; @@ -213,6 +196,7 @@ function contactToForm(contact: ContactCard): FormState { 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) => ({ @@ -233,16 +217,25 @@ function contactToForm(contact: ContactCard): FormState { const photoEntry = contact.media ? Object.values(contact.media).find((m) => m.kind === 'photo') : undefined; - const photoUri = photoEntry?.uri || ''; - const photoMediaType = photoEntry?.mediaType || ''; + // 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; + return { - prefix, given, middle, surname, suffix, full, nickname, + 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, @@ -250,24 +243,60 @@ function contactToForm(contact: ContactCard): FormState { calendarUri: contact.calendarUri || '', schedulingUri: contact.schedulingUri || '', freeBusyUri: contact.freeBusyUri || '', + members: memberIds, }; } -function formToPatch(form: FormState): Partial { - const components: Array<{ kind: string; value: string }> = []; - if (form.prefix) components.push({ kind: 'title', value: form.prefix }); - if (form.given) components.push({ kind: 'given', value: form.given }); - if (form.middle) components.push({ kind: 'given2', value: form.middle }); - if (form.surname) components.push({ kind: 'surname', value: form.surname }); - if (form.suffix) components.push({ kind: 'generation', value: form.suffix }); +/** Group-membership key for a card (RFC 9553 members are keyed by UID). */ +function memberKey(contact: ContactCard): string { + return contact.uid || contact.originalId || contact.id; +} +function memberKeyMatches(key: string, contact: ContactCard): boolean { + const bare = key.startsWith('urn:uuid:') ? key.slice(9) : key; + const bareUid = contact.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact.uid; + return key === contact.id || bare === contact.id + || (!!contact.originalId && (key === contact.originalId || bare === contact.originalId)) + || (!!contact.uid && (key === contact.uid || bare === bareUid)); +} + +/** + * Build the JMAP patch. In edit mode every collection the form owns that ended + * up empty is sent as `null` so the server clears it - omitting the key would + * mean "unchanged" and the removed phone/address/note would come right back. + */ +function formToPatch( + form: FormState, + existing: ContactCard | undefined, + asGroup: boolean, + allContacts: ContactCard[], +): Partial { + const isEdit = !!existing; + const orgName = form.orgs[0]?.name.trim() || ''; + + // Organization cards carry no personal name components; the org name goes + // into `name.full` below. A group name lives in `given`. + const components: Array<{ kind: string; value: string }> = []; + if (!form.isOrg) { + if (form.prefix.trim()) components.push({ kind: 'title', value: form.prefix.trim() }); + if (form.given.trim()) components.push({ kind: 'given', value: form.given.trim() }); + if (form.middle.trim()) components.push({ kind: 'given2', value: form.middle.trim() }); + if (form.surname.trim()) components.push({ kind: 'surname', value: form.surname.trim() }); + if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() }); + } + + // Without personal name components, carry the organization name in + // `name.full` so servers and other clients have something to display. + const full = form.full.trim() || (form.isOrg ? orgName : ''); const name: ContactCard['name'] | undefined = - components.length > 0 || form.full - ? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(form.full ? { full: form.full } : {}) } + components.length > 0 || full + ? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) } : undefined; - const nicknames: Record | undefined = - form.nickname.trim() ? { n0: { name: form.nickname.trim() } } : undefined; + const nicknames: Record = {}; + form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => { + nicknames[`n${i}`] = { name: n }; + }); const emails: Record = {}; form.emails.filter((e) => e.address.trim()).forEach((e, i) => { @@ -324,7 +353,7 @@ function formToPatch(form: FormState): Partial { const anniversaries: Record = {}; form.anniversaries.forEach((a, i) => { - const date = stringToAnniversaryDate(a.date); + const date = stringToPartialDate(a.date); if (!date) return; anniversaries[`an${i + 1}`] = { kind: a.kind as ContactAnniversary['kind'], date }; }); @@ -334,6 +363,7 @@ function formToPatch(form: FormState): Partial { onlineServices[`os${i + 1}`] = { uri: s.uri.trim(), ...(s.service.trim() ? { service: s.service.trim() } : {}), + ...(s.label.trim() ? { label: s.label.trim() } : {}), }; }); @@ -356,9 +386,18 @@ function formToPatch(form: FormState): Partial { if (k.trim()) keywords[k.trim()] = true; }); + // Media: keep every non-photo entry (logo, sound) the card already has and + // write the photo back under its original key. const media: Record = {}; + let photoKey = 'photo'; + if (existing?.media) { + for (const [key, m] of Object.entries(existing.media)) { + if (m.kind === 'photo') photoKey = key; + else media[key] = m; + } + } if (form.photoUri.trim()) { - media.photo = { + media[photoKey] = { kind: 'photo', uri: form.photoUri.trim(), ...(form.photoMediaType ? { mediaType: form.photoMediaType } : {}), @@ -368,32 +407,75 @@ function formToPatch(form: FormState): Partial { const speakToAs = form.grammaticalGender || form.pronouns.trim() ? { - ...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}), - ...(form.pronouns.trim() - ? { pronouns: { p0: { pronouns: form.pronouns.trim() } } } - : {}), - } + ...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}), + ...(form.pronouns.trim() + ? { pronouns: { p0: { pronouns: form.pronouns.trim() } } } + : {}), + } : undefined; - return { - ...(name ? { name } : {}), - ...(nicknames ? { nicknames } : {}), - ...(Object.keys(emails).length ? { emails } : {}), - ...(Object.keys(phones).length ? { phones } : {}), - ...(Object.keys(addresses).length ? { addresses } : {}), - ...(Object.keys(organizations).length ? { organizations } : {}), - ...(Object.keys(titles).length ? { titles } : {}), - ...(Object.keys(anniversaries).length ? { anniversaries } : {}), - ...(Object.keys(onlineServices).length ? { onlineServices } : {}), - ...(Object.keys(personalInfo).length ? { personalInfo } : {}), - ...(Object.keys(notes).length ? { notes } : {}), - ...(Object.keys(keywords).length ? { keywords } : {}), - ...(speakToAs ? { speakToAs } : {}), - ...(form.calendarUri.trim() ? { calendarUri: form.calendarUri.trim() } : {}), - ...(form.schedulingUri.trim() ? { schedulingUri: form.schedulingUri.trim() } : {}), - ...(form.freeBusyUri.trim() ? { freeBusyUri: form.freeBusyUri.trim() } : {}), - media, + // Collections: value when non-empty; `null` on edit when the card had one + // before (clears it server-side); omitted otherwise. + const collection = (key: keyof ContactCard, value: Record): Record => { + if (Object.keys(value).length > 0) return { [key]: value }; + if (isEdit && existing?.[key] !== undefined) return { [key]: null }; + return {}; }; + const scalar = (key: keyof ContactCard, value: string): Record => { + if (value) return { [key]: value }; + if (isEdit && existing?.[key] !== undefined) return { [key]: null }; + return {}; + }; + + // Only send `kind` when this form owns the answer: switching a card between + // person and organization. Leave other kinds (group, location, ...) untouched. + const kind: Partial = asGroup + ? { kind: 'group' } + : form.isOrg + ? { kind: 'org' } + : existing?.kind === 'org' ? { kind: 'individual' } : {}; + + const patch: Record = { + ...kind, + ...(name ? { name } : isEdit && existing?.name ? { name: null } : {}), + ...collection('nicknames', nicknames), + ...collection('emails', emails), + ...collection('phones', phones), + ...collection('addresses', addresses), + ...collection('organizations', organizations), + ...collection('titles', titles), + ...collection('anniversaries', anniversaries), + ...collection('onlineServices', onlineServices), + ...collection('personalInfo', personalInfo), + ...collection('notes', notes), + ...collection('keywords', keywords), + ...(speakToAs ? { speakToAs } : isEdit && existing?.speakToAs ? { speakToAs: null } : {}), + ...scalar('calendarUri', form.calendarUri.trim()), + ...scalar('schedulingUri', form.schedulingUri.trim()), + ...scalar('freeBusyUri', form.freeBusyUri.trim()), + ...collection('media', media), + }; + + if (asGroup) { + // Start from the stored map so members we cannot resolve locally survive; + // drop the resolved ones that were deselected and add the new picks. + const members: Record = { ...(existing?.members || {}) }; + const selected = form.members + .map((id) => allContacts.find((c) => c.id === id)) + .filter((c): c is ContactCard => !!c); + for (const key of Object.keys(members)) { + const owner = allContacts.find((c) => memberKeyMatches(key, c)); + if (owner && !selected.some((s) => s.id === owner.id)) delete members[key]; + } + for (const contact of selected) { + if (!Object.keys(members).some((key) => memberKeyMatches(key, contact))) { + members[memberKey(contact)] = true; + } + } + patch.members = members; + } + + return patch as Partial; } const EMAIL_CONTEXTS: Array<{ value: string; label: string }> = [ @@ -434,15 +516,55 @@ const PERSONAL_INFO_LEVELS: Array<{ value: string; label: string }> = [ { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, ]; +// Same vocabulary as the webmail form and the vCard SEX mapping +// (M/F/O/N/U), so a card renders the same value in both apps. const GENDER_OPTIONS: Array<{ value: string; label: string }> = [ { value: '', label: 'Unspecified' }, { value: 'masculine', label: 'Masculine' }, { value: 'feminine', label: 'Feminine' }, - { value: 'common', label: 'Common' }, - { value: 'neuter', label: 'Neuter' }, - { value: 'animate', label: 'Animate' }, - { value: 'inanimate', label: 'Inanimate' }, + { value: 'other', label: 'Other' }, + { value: 'none', label: 'None' }, + { value: 'unknown', label: 'Unknown' }, ]; +const KIND_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'person', label: 'Person' }, + { value: 'org', label: 'Organization' }, +]; + +const MAX_PHOTO_DIM = 512; +const PHOTO_QUALITY = 0.85; + +/** + * Downscale a picked image to at most 512px and embed it as a JPEG data URI + * (the webmail's processImageFile). Falls back to the picker's own base64 + * when the manipulator is unavailable. + */ +async function processPickedImage(asset: ImagePicker.ImagePickerAsset): Promise<{ uri: string; mediaType: string }> { + try { + const width = asset.width || 0; + const height = asset.height || 0; + const longest = Math.max(width, height); + const actions: ImageManipulator.Action[] = []; + if (longest > MAX_PHOTO_DIM) { + actions.push({ resize: width >= height ? { width: MAX_PHOTO_DIM } : { height: MAX_PHOTO_DIM } }); + } else if (!longest) { + actions.push({ resize: { width: MAX_PHOTO_DIM } }); + } + const result = await ImageManipulator.manipulateAsync(asset.uri, actions, { + compress: PHOTO_QUALITY, + format: ImageManipulator.SaveFormat.JPEG, + base64: true, + }); + if (result.base64) { + return { uri: `data:image/jpeg;base64,${result.base64}`, mediaType: 'image/jpeg' }; + } + } catch (err) { + console.warn('[contact-form] photo downscale failed, using original', err); + } + const mime = asset.mimeType || 'image/jpeg'; + if (asset.base64) return { uri: `data:${mime};base64,${asset.base64}`, mediaType: mime }; + return { uri: asset.uri, mediaType: mime }; +} function Pills({ value, options, onChange, @@ -551,13 +673,14 @@ export default function ContactFormScreen() { const styles = React.useMemo(() => makeStyles(c), [c]); const navigation = useNavigation