import React from 'react'; import { View, Text, StyleSheet, Pressable, ScrollView, TextInput, Alert, KeyboardAvoidingView, Platform, Modal, Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; 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, 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 { 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'; type Nav = NativeStackNavigationProp; type Route = RouteProp; interface EmailDraft { address: string; context: string } interface PhoneDraft { number: string; context: string; feature: string } interface AddressDraft { street: string; locality: string; region: string; postcode: string; country: string; context: string; } interface OrgDraft { name: string; department: string; jobTitle: string; role: string } interface AnniversaryDraft { kind: string; date: string } interface OnlineDraft { uri: string; service: string; label: string } interface PersonalInfoDraft { kind: string; level: string; value: string } interface NoteDraft { note: string } interface FormState { isOrg: boolean; prefix: string; given: string; middle: string; surname: string; suffix: string; full: string; nicknames: string[]; emails: EmailDraft[]; phones: PhoneDraft[]; addresses: AddressDraft[]; orgs: OrgDraft[]; anniversaries: AnniversaryDraft[]; online: OnlineDraft[]; personalInfo: PersonalInfoDraft[]; notes: NoteDraft[]; keywords: string[]; grammaticalGender: string; pronouns: string; calendarUri: string; schedulingUri: string; freeBusyUri: string; addressBookId: string; photoUri: string; photoMediaType: string; /** Group members (contact ids from the store), only used when editing a group. */ members: string[]; } function blankForm(): FormState { return { isOrg: false, prefix: '', given: '', middle: '', surname: '', suffix: '', full: '', nicknames: [''], emails: [{ address: '', context: '' }], phones: [], addresses: [], orgs: [], anniversaries: [], online: [], personalInfo: [], notes: [], keywords: [], grammaticalGender: '', pronouns: '', calendarUri: '', schedulingUri: '', freeBusyUri: '', addressBookId: '', photoUri: '', photoMediaType: '', members: [], }; } function findNameComponent(contact: ContactCard | undefined, ...kinds: string[]): string { if (!contact?.name?.components) return ''; const found = contact.name.components.find((c) => kinds.includes(c.kind as string)); return found?.value || ''; } function addressToFlat(a: ContactAddress): AddressDraft { if (a.components && a.components.length > 0) { const collect = (kind: string) => a.components!.filter((c) => c.kind === kind).map((c) => c.value).join(' '); const number = collect('number'); const name = collect('name'); return { street: [number, name].filter(Boolean).join(' ') || a.street || '', locality: collect('locality') || a.locality || '', region: collect('region') || a.region || '', postcode: collect('postcode') || a.postcode || '', country: collect('country') || a.country || '', context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', }; } return { street: a.street || '', locality: a.locality || '', region: a.region || '', postcode: a.postcode || '', country: a.country || '', context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', }; } function contactToForm(contact: ContactCard, memberIds: string[]): FormState { const prefix = findNameComponent(contact, 'title', 'prefix'); const given = findNameComponent(contact, 'given'); const middle = findNameComponent(contact, 'given2', 'additional', 'middle'); const surname = findNameComponent(contact, 'surname'); const suffix = findNameComponent(contact, 'generation', 'suffix'); const full = contact.name?.full || ''; 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; return { isOrg, prefix, given, middle, surname, suffix, full, nicknames: nicknames.length > 0 ? nicknames : [''], emails: emails.length > 0 ? emails : [{ address: '', context: '' }], phones, addresses, orgs, anniversaries, online, personalInfo, notes, keywords, addressBookId, photoUri, photoMediaType, grammaticalGender, pronouns, calendarUri: contact.calendarUri || '', schedulingUri: contact.schedulingUri || '', freeBusyUri: contact.freeBusyUri || '', members: memberIds, }; } /** Group-membership key for a card (RFC 9553 members are keyed by UID). */ function memberKey(contact: ContactCard): string { return contact.uid || contact.originalId || contact.id; } function memberKeyMatches(key: string, contact: ContactCard): boolean { const bare = key.startsWith('urn:uuid:') ? key.slice(9) : key; const bareUid = contact.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact.uid; return key === contact.id || bare === contact.id || (!!contact.originalId && (key === contact.originalId || bare === contact.originalId)) || (!!contact.uid && (key === contact.uid || bare === bareUid)); } /** * Build the JMAP patch. In edit mode every collection the form owns that ended * up empty is sent as `null` so the server clears it - omitting the key would * mean "unchanged" and the removed phone/address/note would come right back. */ function formToPatch( form: FormState, existing: ContactCard | undefined, asGroup: boolean, allContacts: ContactCard[], ): Partial { const isEdit = !!existing; const orgName = form.orgs[0]?.name.trim() || ''; // Organization cards carry no personal name components; the org name goes // into `name.full` below. A group name lives in `given`. const components: Array<{ kind: string; value: string }> = []; if (!form.isOrg) { if (form.prefix.trim()) components.push({ kind: 'title', value: form.prefix.trim() }); if (form.given.trim()) components.push({ kind: 'given', value: form.given.trim() }); if (form.middle.trim()) components.push({ kind: 'given2', value: form.middle.trim() }); if (form.surname.trim()) components.push({ kind: 'surname', value: form.surname.trim() }); if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() }); } // 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 || full ? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) } : undefined; const nicknames: Record = {}; form.nicknames.map((n) => n.trim()).filter(Boolean).forEach((n, i) => { nicknames[`n${i}`] = { name: n }; }); const emails: Record = {}; form.emails.filter((e) => e.address.trim()).forEach((e, i) => { emails[`e${i + 1}`] = { address: e.address.trim(), ...(e.context ? { contexts: { [e.context]: true } } : {}), }; }); const phones: Record = {}; form.phones.filter((p) => p.number.trim()).forEach((p, i) => { phones[`p${i + 1}`] = { number: p.number.trim(), ...(p.context ? { contexts: { [p.context]: true } } : {}), ...(p.feature ? { features: { [p.feature]: true } } : {}), }; }); const addresses: Record = {}; form.addresses .filter((a) => a.street.trim() || a.locality.trim() || a.country.trim() || a.region.trim() || a.postcode.trim()) .forEach((a, i) => { const comps: Array<{ kind: string; value: string }> = []; if (a.street.trim()) comps.push({ kind: 'name', value: a.street.trim() }); if (a.locality.trim()) comps.push({ kind: 'locality', value: a.locality.trim() }); if (a.region.trim()) comps.push({ kind: 'region', value: a.region.trim() }); if (a.postcode.trim()) comps.push({ kind: 'postcode', value: a.postcode.trim() }); if (a.country.trim()) comps.push({ kind: 'country', value: a.country.trim() }); addresses[`a${i + 1}`] = { components: comps, isOrdered: true, defaultSeparator: ', ', ...(a.context ? { contexts: { [a.context]: true } } : {}), }; }); const organizations: Record = {}; const titles: Record = {}; form.orgs.forEach((o, i) => { if (o.name.trim() || o.department.trim()) { const units = o.department.trim() ? [{ name: o.department.trim() }] : undefined; organizations[`o${i + 1}`] = { ...(o.name.trim() ? { name: o.name.trim() } : {}), ...(units ? { units } : {}), }; } if (o.jobTitle.trim()) { titles[`t${i + 1}`] = { name: o.jobTitle.trim(), kind: 'title' }; } if (o.role.trim()) { titles[`r${i + 1}`] = { name: o.role.trim(), kind: 'role' }; } }); const anniversaries: Record = {}; form.anniversaries.forEach((a, i) => { const date = stringToPartialDate(a.date); if (!date) return; anniversaries[`an${i + 1}`] = { kind: a.kind as ContactAnniversary['kind'], date }; }); const onlineServices: Record = {}; form.online.filter((s) => s.uri.trim()).forEach((s, i) => { onlineServices[`os${i + 1}`] = { uri: s.uri.trim(), ...(s.service.trim() ? { service: s.service.trim() } : {}), ...(s.label.trim() ? { label: s.label.trim() } : {}), }; }); const personalInfo: Record = {}; form.personalInfo.filter((p) => p.value.trim()).forEach((p, i) => { personalInfo[`pi${i + 1}`] = { kind: p.kind as ContactPersonalInfo['kind'], value: p.value.trim(), ...(p.level ? { level: p.level as 'high' | 'medium' | 'low' } : {}), }; }); const notes: Record = {}; form.notes.forEach((n, i) => { if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() }; }); const keywords: Record = {}; form.keywords.forEach((k) => { if (k.trim()) keywords[k.trim()] = true; }); // Media: keep every non-photo entry (logo, sound) the card already has and // write the photo back under its original key. const media: Record = {}; let photoKey = 'photo'; if (existing?.media) { for (const [key, m] of Object.entries(existing.media)) { if (m.kind === 'photo') photoKey = key; else media[key] = m; } } if (form.photoUri.trim()) { media[photoKey] = { kind: 'photo', uri: form.photoUri.trim(), ...(form.photoMediaType ? { mediaType: form.photoMediaType } : {}), }; } const speakToAs = form.grammaticalGender || form.pronouns.trim() ? { ...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}), ...(form.pronouns.trim() ? { pronouns: { p0: { pronouns: form.pronouns.trim() } } } : {}), } : undefined; // Collections: value when non-empty; `null` on edit when the card had one // before (clears it server-side); omitted otherwise. const collection = (key: keyof ContactCard, value: Record): Record => { if (Object.keys(value).length > 0) return { [key]: value }; if (isEdit && existing?.[key] !== undefined) return { [key]: null }; return {}; }; const scalar = (key: keyof ContactCard, value: string): Record => { if (value) return { [key]: value }; if (isEdit && existing?.[key] !== undefined) return { [key]: null }; return {}; }; // Only send `kind` when this form owns the answer: switching a card between // person and organization. Leave other kinds (group, location, ...) untouched. const kind: Partial = asGroup ? { kind: 'group' } : form.isOrg ? { kind: 'org' } : existing?.kind === 'org' ? { kind: 'individual' } : {}; const patch: Record = { ...kind, ...(name ? { name } : isEdit && existing?.name ? { name: null } : {}), ...collection('nicknames', nicknames), ...collection('emails', emails), ...collection('phones', phones), ...collection('addresses', addresses), ...collection('organizations', organizations), ...collection('titles', titles), ...collection('anniversaries', anniversaries), ...collection('onlineServices', onlineServices), ...collection('personalInfo', personalInfo), ...collection('notes', notes), ...collection('keywords', keywords), ...(speakToAs ? { speakToAs } : isEdit && existing?.speakToAs ? { speakToAs: null } : {}), ...scalar('calendarUri', form.calendarUri.trim()), ...scalar('schedulingUri', form.schedulingUri.trim()), ...scalar('freeBusyUri', form.freeBusyUri.trim()), ...collection('media', media), }; if (asGroup) { // Start from the stored map so members we cannot resolve locally survive; // drop the resolved ones that were deselected and add the new picks. const members: Record = { ...(existing?.members || {}) }; const selected = form.members .map((id) => allContacts.find((c) => c.id === id)) .filter((c): c is ContactCard => !!c); for (const key of Object.keys(members)) { const owner = allContacts.find((c) => memberKeyMatches(key, c)); if (owner && !selected.some((s) => s.id === owner.id)) delete members[key]; } for (const contact of selected) { if (!Object.keys(members).some((key) => memberKeyMatches(key, contact))) { members[memberKey(contact)] = true; } } patch.members = members; } return patch as Partial; } const EMAIL_CONTEXTS: Array<{ value: string; label: string }> = [ { value: '', label: 'None' }, { value: 'work', label: 'Work' }, { value: 'private', label: 'Private' }, ]; const PHONE_CONTEXTS = EMAIL_CONTEXTS; const ADDRESS_CONTEXTS: Array<{ value: string; label: string }> = [ { value: '', label: 'None' }, { value: 'work', label: 'Work' }, { value: 'private', label: 'Private' }, ]; const PHONE_FEATURES: Array<{ value: string; label: string }> = [ { value: '', label: 'Phone' }, { value: 'voice', label: 'Voice' }, { value: 'cell', label: 'Mobile' }, { value: 'fax', label: 'Fax' }, { value: 'pager', label: 'Pager' }, { value: 'video', label: 'Video' }, { value: 'text', label: 'Text' }, ]; const ANNIVERSARY_KINDS: Array<{ value: string; label: string }> = [ { value: 'birth', label: 'Birthday' }, { value: 'wedding', label: 'Anniversary' }, { value: 'death', label: 'Memorial' }, { value: 'other', label: 'Other' }, ]; const PERSONAL_INFO_KINDS: Array<{ value: string; label: string }> = [ { value: 'hobby', label: 'Hobby' }, { value: 'expertise', label: 'Expertise' }, { value: 'interest', label: 'Interest' }, { value: 'other', label: 'Other' }, ]; const PERSONAL_INFO_LEVELS: Array<{ value: string; label: string }> = [ { value: '', label: '–' }, { value: 'low', label: 'Low' }, { 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: '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, }: { value: string; options: Array<{ value: string; label: string }>; onChange: (v: string) => void; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( {options.map((o) => { const active = value === o.value; return ( onChange(o.value)} style={[styles.pill, active && styles.pillActive]} > {o.label} ); })} ); } function Section({ icon, label, children, collapsible = false, defaultOpen = true, }: { icon: React.ReactNode; label: string; children: React.ReactNode; collapsible?: boolean; defaultOpen?: boolean; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const [open, setOpen] = React.useState(defaultOpen); const isOpen = collapsible ? open : true; return ( collapsible && setOpen((o) => !o)} style={styles.sectionHeader} disabled={!collapsible} > {icon} {label} {collapsible && ( isOpen ? : )} {isOpen && {children}} ); } function Field({ label, children }: { label?: string; children: React.ReactNode }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( {!!label && {label}} {children} ); } function RemovableRow({ onRemove, children, }: { onRemove: () => void; children: React.ReactNode; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( {children} ); } function AddButton({ onPress, label }: { onPress: () => void; label: string }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( [styles.addBtn, pressed && styles.addBtnPressed]}> {label} ); } export default function ContactFormScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const navigation = useNavigation