mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
1714 lines
66 KiB
TypeScript
1714 lines
66 KiB
TypeScript
import React from 'react';
|
||
import {
|
||
View, Text, StyleSheet, Pressable, ScrollView, TextInput, Alert,
|
||
KeyboardAvoidingView, Platform, Modal,
|
||
} from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { usePreventRemove, 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 '../components/icons';
|
||
import type { RootStackParamList } from '../navigation/types';
|
||
import type { ContactCard } from '../api/types';
|
||
import { useContactsStore, selectGroupMembers } from '../stores/contacts-store';
|
||
import {
|
||
getContactKeywords, getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef,
|
||
stringToPartialDate, type ContactPhotoRef,
|
||
} from '../lib/contact-utils';
|
||
import { blankForm, contactToForm, formToPatch, type FormState } from '../lib/contact-form';
|
||
import { ContactMediaUnreadError, 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';
|
||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||
|
||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
||
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
||
|
||
type Option = { value: string; label: string };
|
||
|
||
function formOptions(t: TranslateFn) {
|
||
const contexts: Option[] = [
|
||
{ value: '', label: t('contacts.form.context_none', 'None') },
|
||
{ value: 'work', label: t('contacts.form.context_work', 'Work') },
|
||
{ value: 'private', label: t('contacts.form.context_private', 'Private') },
|
||
];
|
||
const phoneFeatures: Option[] = [
|
||
{ value: '', label: t('contacts.form.phone', 'Phone') },
|
||
{ value: 'voice', label: t('contacts.form.phone_voice', 'Voice') },
|
||
{ value: 'mobile', label: t('contacts.form.phone_cell', 'Mobile') },
|
||
{ value: 'fax', label: t('contacts.form.phone_fax', 'Fax') },
|
||
{ value: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
|
||
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
|
||
{ value: 'text', label: t('contacts.form.phone_text', 'Text') },
|
||
];
|
||
const anniversaryKinds: Option[] = [
|
||
{ value: 'birth', label: t('contacts.form.anniversary_birth', 'Birthday') },
|
||
{ value: 'wedding', label: t('contacts.form.anniversary_wedding', 'Anniversary') },
|
||
{ value: 'death', label: t('contacts.form.anniversary_death', 'Memorial') },
|
||
{ value: 'other', label: t('contacts.form.anniversary_other', 'Other') },
|
||
];
|
||
const personalInfoKinds: Option[] = [
|
||
{ value: 'hobby', label: t('contacts.form.personal_hobby', 'Hobby') },
|
||
{ value: 'expertise', label: t('contacts.form.personal_expertise', 'Expertise') },
|
||
{ value: 'interest', label: t('contacts.form.personal_interest', 'Interest') },
|
||
{ value: 'other', label: t('contacts.form.personal_other', 'Other') },
|
||
];
|
||
const personalInfoLevels: Option[] = [
|
||
{ value: '', label: '–' },
|
||
{ value: 'low', label: t('contacts.form.level_low', 'Low') },
|
||
{ value: 'medium', label: t('contacts.form.level_medium', 'Medium') },
|
||
{ value: 'high', label: t('contacts.form.level_high', '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: Option[] = [
|
||
{ value: '', label: t('contacts.form.gender_unspecified', 'Unspecified') },
|
||
{ value: 'masculine', label: t('contacts.form.gender_male', 'Male') },
|
||
{ value: 'feminine', label: t('contacts.form.gender_female', 'Female') },
|
||
{ value: 'other', label: t('contacts.form.gender_other', 'Other') },
|
||
{ value: 'none', label: t('contacts.form.gender_none', 'Not applicable') },
|
||
{ value: 'unknown', label: t('contacts.form.gender_unknown', 'Unknown') },
|
||
];
|
||
const kind: Option[] = [
|
||
{ value: 'person', label: t('contacts.form.type_person', 'Person') },
|
||
{ value: 'org', label: t('contacts.form.type_organization', 'Organization') },
|
||
];
|
||
return { contexts, phoneFeatures, anniversaryKinds, personalInfoKinds, personalInfoLevels, gender, kind };
|
||
}
|
||
|
||
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 (
|
||
<View style={styles.pillRow}>
|
||
{options.map((o) => {
|
||
const active = value === o.value;
|
||
return (
|
||
<Pressable
|
||
key={o.value}
|
||
onPress={() => onChange(o.value)}
|
||
style={[styles.pill, active && styles.pillActive]}
|
||
accessibilityRole="button"
|
||
accessibilityState={{ selected: active }}
|
||
>
|
||
<Text style={[styles.pillText, active && styles.pillTextActive]}>{o.label}</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<View style={styles.section}>
|
||
<Pressable
|
||
onPress={() => collapsible && setOpen((o) => !o)}
|
||
style={styles.sectionHeader}
|
||
disabled={!collapsible}
|
||
accessibilityRole={collapsible ? 'button' : 'header'}
|
||
accessibilityState={collapsible ? { expanded: isOpen } : undefined}
|
||
>
|
||
<View style={styles.sectionIcon}>{icon}</View>
|
||
<Text style={styles.sectionLabel}>{label}</Text>
|
||
{collapsible && (
|
||
isOpen
|
||
? <ChevronDown size={14} color={c.textMuted} />
|
||
: <ChevronRight size={14} color={c.textMuted} />
|
||
)}
|
||
</Pressable>
|
||
{isOpen && <View style={styles.sectionBody}>{children}</View>}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label?: string; children: React.ReactNode }) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
return (
|
||
<View style={styles.field}>
|
||
{!!label && <Text style={styles.fieldLabel}>{label}</Text>}
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function RemovableRow({
|
||
onRemove, children,
|
||
}: {
|
||
onRemove: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const t = useLocaleStore((s) => s.t);
|
||
return (
|
||
<View style={styles.removableRow}>
|
||
<View style={{ flex: 1 }}>{children}</View>
|
||
<Pressable
|
||
onPress={onRemove}
|
||
hitSlop={8}
|
||
style={styles.removeBtn}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.remove', 'Remove')}
|
||
>
|
||
<XIcon size={14} color={c.textMuted} />
|
||
</Pressable>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function AddButton({ onPress, label }: { onPress: () => void; label: string }) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
style={({ pressed }) => [styles.addBtn, pressed && styles.addBtnPressed]}
|
||
accessibilityRole="button"
|
||
>
|
||
<Plus size={14} color={c.primary} />
|
||
<Text style={styles.addBtnLabel}>{label}</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
export default function ContactFormScreen() {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const navigation = useNavigation<Nav>();
|
||
const route = useRoute<Route>();
|
||
const t = useLocaleStore((s) => s.t);
|
||
const opts = React.useMemo(() => formOptions(t), [t]);
|
||
const { contactId, addressBookId: initialBook, asGroup: asGroupParam, prefill, memberIds: initialMemberIds } = route.params || {};
|
||
const isEdit = !!contactId;
|
||
|
||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||
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),
|
||
[allContacts, contactId],
|
||
);
|
||
// An edit link (`/contacts/<id>/edit`) does not know it names a group.
|
||
const asGroup = asGroupParam || existing?.kind === 'group';
|
||
const existingKeywords = React.useMemo(() => {
|
||
const counts = new Map<string, number>();
|
||
for (const contact of allContacts) {
|
||
for (const kw of getContactKeywords(contact)) {
|
||
counts.set(kw, (counts.get(kw) || 0) + 1);
|
||
}
|
||
}
|
||
return Array.from(counts.entries())
|
||
.map(([keyword, count]) => ({ keyword, count }))
|
||
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
||
}, [allContacts]);
|
||
|
||
const [form, setForm] = React.useState<FormState>(() => {
|
||
if (existing) {
|
||
const members = asGroup
|
||
? selectGroupMembers({ contacts: allContacts }, existing.id).map((m) => m.id)
|
||
: [];
|
||
return contactToForm(existing, members);
|
||
}
|
||
const init = blankForm();
|
||
init.addressBookId = initialBook || getDefaultAddressBookId() || '';
|
||
if (initialMemberIds?.length) init.members = initialMemberIds;
|
||
if (prefill?.email || prefill?.name) {
|
||
// "Add sender to contacts": split the display name like the webmail
|
||
// (first word → given, rest → surname) and never let a mailbox-shaped
|
||
// name through (#672). A `/contacts/new` link may carry only a name.
|
||
const mailbox = prefill.email
|
||
? splitMailbox(prefill.name ? `${prefill.name} <${prefill.email}>` : prefill.email)
|
||
: { email: '', name: prefill.name ?? '' };
|
||
if (mailbox.email) init.emails = [{ address: mailbox.email, context: '' }];
|
||
const parts = (mailbox.name || '').split(/\s+/).filter(Boolean);
|
||
if (parts.length > 0) {
|
||
init.given = parts[0];
|
||
init.surname = parts.slice(1).join(' ');
|
||
}
|
||
}
|
||
return init;
|
||
});
|
||
const [keywordInput, setKeywordInput] = React.useState('');
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [dirty, setDirty] = React.useState(!!prefill || !!initialMemberIds?.length);
|
||
const [confirmDiscard, setConfirmDiscard] = React.useState(false);
|
||
// Return walks the name fields in reading order, then on to the email.
|
||
const prefixRef = React.useRef<TextInput>(null);
|
||
const suffixRef = React.useRef<TextInput>(null);
|
||
const givenRef = React.useRef<TextInput>(null);
|
||
const middleRef = React.useRef<TextInput>(null);
|
||
const surnameRef = React.useRef<TextInput>(null);
|
||
const orgRef = React.useRef<TextInput>(null);
|
||
const fullRef = React.useRef<TextInput>(null);
|
||
const firstEmailRef = React.useRef<TextInput>(null);
|
||
const [datePickerIndex, setDatePickerIndex] = React.useState<number | null>(null);
|
||
const [memberPickerOpen, setMemberPickerOpen] = React.useState(false);
|
||
|
||
// An edit link can open the form before the contacts have loaded: the
|
||
// card's fields go in when it arrives, unless the user has typed already.
|
||
const filledFromExisting = React.useRef(!!existing);
|
||
React.useEffect(() => {
|
||
if (filledFromExisting.current || !existing) return;
|
||
filledFromExisting.current = true;
|
||
if (dirty) return;
|
||
const members = asGroup
|
||
? selectGroupMembers({ contacts: allContacts }, existing.id).map((m) => m.id)
|
||
: [];
|
||
setForm(contactToForm(existing, members));
|
||
}, [existing, dirty, asGroup, allContacts]);
|
||
|
||
React.useEffect(() => {
|
||
if (!form.addressBookId) {
|
||
const fallback = getDefaultAddressBookId();
|
||
if (fallback) setForm((f) => ({ ...f, addressBookId: fallback }));
|
||
}
|
||
}, [addressBooks, form.addressBookId, getDefaultAddressBookId]);
|
||
|
||
const updateForm = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||
setDirty(true);
|
||
setForm((f) => ({ ...f, [key]: value }));
|
||
};
|
||
|
||
// Every way out asks about unsaved changes: the header back, Android's
|
||
// back and the modal's swipe-down. Only the header back did before.
|
||
const allowLeaveRef = React.useRef(false);
|
||
const pendingLeaveRef = React.useRef<(() => void) | null>(null);
|
||
usePreventRemove(dirty, ({ data }) => {
|
||
if (allowLeaveRef.current) {
|
||
navigation.dispatch(data.action);
|
||
return;
|
||
}
|
||
pendingLeaveRef.current = () => navigation.dispatch(data.action);
|
||
setConfirmDiscard(true);
|
||
});
|
||
const leave = (go: () => void) => {
|
||
allowLeaveRef.current = true;
|
||
go();
|
||
};
|
||
|
||
const handleBack = () => {
|
||
navigation.goBack();
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
// Never turn an edit of a card that isn't there into a new card.
|
||
if (isEdit && !existing) return;
|
||
const orgName = form.orgs[0]?.name.trim() || '';
|
||
if (asGroup) {
|
||
if (!form.given.trim()) {
|
||
Alert.alert(
|
||
t('contacts.form.missing_info', 'Missing info'),
|
||
t('contacts.groups.name_required', 'Group name is required'),
|
||
);
|
||
return;
|
||
}
|
||
} else if (form.isOrg) {
|
||
if (!orgName) {
|
||
Alert.alert(
|
||
t('contacts.form.missing_info', 'Missing info'),
|
||
t('contacts.form.org_name_required', 'Add the organization name.'),
|
||
);
|
||
return;
|
||
}
|
||
} else {
|
||
const hasName = form.given.trim() || form.surname.trim() || form.full.trim();
|
||
const hasEmail = form.emails.some((e) => e.address.trim());
|
||
// An organization name identifies the card just as well as a personal name.
|
||
if (!hasName && !hasEmail && !orgName) {
|
||
Alert.alert(
|
||
t('contacts.form.missing_info', 'Missing info'),
|
||
t('contacts.form.name_or_email_required', 'Add a name or at least one email.'),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
if (!form.addressBookId) {
|
||
Alert.alert(
|
||
t('contacts.form.missing_address_book', 'Missing address book'),
|
||
t('contacts.form.address_book_required', 'Select an address book to save this contact.'),
|
||
);
|
||
return;
|
||
}
|
||
// Email validity
|
||
for (const e of form.emails) {
|
||
if (e.address.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.address.trim())) {
|
||
Alert.alert(
|
||
t('contacts.form.invalid_email_title', 'Invalid email'),
|
||
t('contacts.form.invalid_email_message', '"{address}" is not a valid email address.', { address: e.address }),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
// Dates must be structured (RFC 9553 PartialDate) - the server rejects
|
||
// free text, and a rejected date used to take the whole card with it.
|
||
for (const a of form.anniversaries) {
|
||
if (a.date.trim() && !stringToPartialDate(a.date)) {
|
||
Alert.alert(
|
||
t('contacts.form.invalid_date_title', 'Invalid date'),
|
||
t(
|
||
'contacts.form.invalid_date_message',
|
||
'"{date}" is not a date. Use YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD.',
|
||
{ date: a.date },
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
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, !!existing);
|
||
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, options);
|
||
leave(() => navigation.goBack());
|
||
} else {
|
||
const created = await createContact(patch, form.addressBookId, options);
|
||
if (asGroup) leave(() => navigation.replace('GroupDetail', { groupId: created.id }));
|
||
else leave(() => navigation.replace('ContactDetail', { contactId: created.id }));
|
||
}
|
||
} catch (err) {
|
||
Alert.alert(
|
||
asGroup
|
||
? t('contacts.groups.save_failed', 'Failed to save group')
|
||
: t('contacts.form.save_failed', 'Failed to save contact'),
|
||
err instanceof ContactMediaUnreadError
|
||
? t('contacts.form.photo_media_unread', "The contact's photos could not be read from the server, so nothing was saved. Try again.")
|
||
: err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'),
|
||
);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const existingName = existing ? getContactDisplayName(existing) : '';
|
||
const headerTitle = isEdit
|
||
? existingName
|
||
? t('contacts.form.edit_named', 'Edit {name}', { name: existingName })
|
||
: asGroup ? t('contacts.groups.edit', 'Edit Group') : t('contacts.form.edit_title', 'Edit Contact')
|
||
: asGroup ? t('contacts.groups.create', 'New Group') : t('contacts.form.create_title', 'New Contact');
|
||
|
||
const previewName = form.isOrg
|
||
? (form.orgs[0]?.name || '').trim()
|
||
: [form.given, form.surname].filter(Boolean).join(' ').trim() || form.full.trim();
|
||
|
||
const pickPhoto = async () => {
|
||
// The system photo picker needs no media permission (and Play rejects
|
||
// READ_MEDIA_IMAGES for picking a photo now and then).
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ['images'],
|
||
allowsEditing: true,
|
||
aspect: [1, 1],
|
||
quality: 0.9,
|
||
});
|
||
if (result.canceled || !result.assets[0]) return;
|
||
const { uri, mediaType } = await processPickedImage(result.assets[0]);
|
||
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))
|
||
.filter((m): m is ContactCard => !!m),
|
||
[form.members, allContacts],
|
||
);
|
||
const memberIdSet = React.useMemo(() => new Set(form.members), [form.members]);
|
||
|
||
const addressBookSection = addressBooks.length > 1 && (
|
||
<Section icon={<Book size={16} color={c.textMuted} />} label={t('contacts.address_books.address_book', 'Address Book')}>
|
||
<View style={styles.pillRow}>
|
||
{addressBooks.filter((b) => b.myRights?.mayWrite !== false || b.id === form.addressBookId).map((book) => (
|
||
<Pressable
|
||
key={book.id}
|
||
onPress={() => updateForm('addressBookId', book.id)}
|
||
style={[styles.pill, form.addressBookId === book.id && styles.pillActive]}
|
||
>
|
||
<Text style={[styles.pillText, form.addressBookId === book.id && styles.pillTextActive]}>
|
||
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
</Section>
|
||
);
|
||
|
||
const notesSection = (
|
||
<Section
|
||
icon={<FileText size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.note', 'Notes')}
|
||
collapsible
|
||
defaultOpen={form.notes.length > 0}
|
||
>
|
||
{form.notes.map((n, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('notes', form.notes.filter((_, idx) => idx !== i))}
|
||
>
|
||
<TextInput
|
||
style={[styles.input, styles.multiline]}
|
||
placeholder={t('contacts.form.note_placeholder', 'Add a note...')}
|
||
placeholderTextColor={c.textMuted}
|
||
multiline
|
||
value={n.note}
|
||
onChangeText={(v) => {
|
||
const next = [...form.notes];
|
||
next[i] = { note: v };
|
||
updateForm('notes', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_note', 'Add note')}
|
||
onPress={() => updateForm('notes', [...form.notes, { note: '' }])}
|
||
/>
|
||
</Section>
|
||
);
|
||
|
||
const categoriesSection = (
|
||
<Section
|
||
icon={<Tag size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.categories', 'Categories')}
|
||
collapsible
|
||
defaultOpen={form.keywords.length > 0}
|
||
>
|
||
{form.keywords.length > 0 && (
|
||
<View style={styles.chipRow}>
|
||
{form.keywords.map((kw) => (
|
||
<Pressable
|
||
key={kw}
|
||
onPress={() => updateForm('keywords', form.keywords.filter((k) => k !== kw))}
|
||
style={styles.keywordChip}
|
||
>
|
||
<Text style={styles.keywordChipText}>{kw}</Text>
|
||
<XIcon size={11} color={c.primary} />
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
)}
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.categories_placeholder_mobile', 'Add tag and press Enter')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={keywordInput}
|
||
onChangeText={setKeywordInput}
|
||
onSubmitEditing={() => {
|
||
const k = keywordInput.trim();
|
||
if (k && !form.keywords.includes(k)) {
|
||
updateForm('keywords', [...form.keywords, k]);
|
||
}
|
||
setKeywordInput('');
|
||
}}
|
||
returnKeyType="done"
|
||
/>
|
||
{existingKeywords.length > 0 && (
|
||
<View style={styles.chipRow}>
|
||
{existingKeywords
|
||
.filter((k) => !form.keywords.includes(k.keyword))
|
||
.slice(0, 8)
|
||
.map((k) => (
|
||
<Pressable
|
||
key={k.keyword}
|
||
onPress={() => updateForm('keywords', [...form.keywords, k.keyword])}
|
||
style={styles.suggestedChip}
|
||
>
|
||
<Text style={styles.suggestedChipText}>+ {k.keyword}</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
)}
|
||
</Section>
|
||
);
|
||
|
||
const header = (
|
||
<View style={styles.header}>
|
||
<Pressable
|
||
onPress={handleBack}
|
||
style={styles.headerBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.back', 'Back')}
|
||
>
|
||
<ArrowLeft size={22} color={c.text} />
|
||
</Pressable>
|
||
<Text style={styles.headerTitle} numberOfLines={1}>{headerTitle}</Text>
|
||
<Pressable
|
||
onPress={handleSave}
|
||
disabled={saving}
|
||
style={styles.saveBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
>
|
||
{saving ? (
|
||
<Text style={styles.saveLabel}>
|
||
{isEdit ? t('contacts.form.updating', 'Updating...') : t('contacts.form.creating', 'Creating...')}
|
||
</Text>
|
||
) : (
|
||
<>
|
||
<Check size={16} color={c.primaryForeground} />
|
||
<Text style={styles.saveLabel}>{t('contacts.form.save', 'Save')}</Text>
|
||
</>
|
||
)}
|
||
</Pressable>
|
||
</View>
|
||
);
|
||
|
||
const discardDialog = (
|
||
<Dialog
|
||
visible={confirmDiscard}
|
||
title={t('settings.discard_changes', 'Discard unsaved changes?')}
|
||
message={t('settings.unsaved_changes', 'You have unsaved changes')}
|
||
variant="destructive"
|
||
confirmText={t('settings.discard', 'Discard')}
|
||
onConfirm={() => {
|
||
setConfirmDiscard(false);
|
||
const go = pendingLeaveRef.current ?? (() => navigation.goBack());
|
||
pendingLeaveRef.current = null;
|
||
leave(go);
|
||
}}
|
||
onCancel={() => {
|
||
pendingLeaveRef.current = null;
|
||
setConfirmDiscard(false);
|
||
}}
|
||
/>
|
||
);
|
||
|
||
if (isEdit && !existing) {
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||
<View style={styles.header}>
|
||
<Pressable
|
||
onPress={handleBack}
|
||
style={styles.headerBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.back', 'Back')}
|
||
>
|
||
<ArrowLeft size={22} color={c.text} />
|
||
</Pressable>
|
||
<Text style={styles.headerTitle}>{t('contacts.contact', 'Contact')}</Text>
|
||
</View>
|
||
<View style={styles.missing}>
|
||
<Text style={styles.missingText}>{t('contacts.detail.not_found', 'Contact not found')}</Text>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
if (asGroup) {
|
||
// Groups are a name plus a member list - none of the person fields apply.
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||
{header}
|
||
<KeyboardAvoidingView
|
||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||
style={{ flex: 1 }}
|
||
>
|
||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||
{addressBookSection}
|
||
|
||
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.group', 'Group')}>
|
||
<Field label={t('contacts.groups.name_label', 'Group Name')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.groups.name_placeholder', 'e.g., Team, Family')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.given}
|
||
onChangeText={(v) => updateForm('given', v)}
|
||
autoFocus={!isEdit}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
|
||
<Section icon={<User size={16} color={c.textMuted} />} label={t('contacts.groups.members_with_count', 'Members ({count})', { count: memberContacts.length })}>
|
||
{memberContacts.map((m) => {
|
||
const email = getContactPrimaryEmail(m);
|
||
return (
|
||
<RemovableRow
|
||
key={m.id}
|
||
onRemove={() => updateForm('members', form.members.filter((id) => id !== m.id))}
|
||
>
|
||
<Text style={styles.memberName} numberOfLines={1}>{getContactDisplayName(m) || t('contacts.unnamed', 'Unnamed')}</Text>
|
||
{!!email && <Text style={styles.memberEmail} numberOfLines={1}>{email}</Text>}
|
||
</RemovableRow>
|
||
);
|
||
})}
|
||
<AddButton label={t('contacts.groups.add_members', 'Add members')} onPress={() => setMemberPickerOpen(true)} />
|
||
</Section>
|
||
|
||
{categoriesSection}
|
||
{notesSection}
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
|
||
<ContactPickerSheet
|
||
visible={memberPickerOpen}
|
||
onClose={() => setMemberPickerOpen(false)}
|
||
title={t('contacts.groups.add_members', 'Add members')}
|
||
excludedIds={memberIdSet}
|
||
onSelect={(ids) => {
|
||
setMemberPickerOpen(false);
|
||
const merged = [...form.members];
|
||
for (const id of ids) if (!merged.includes(id)) merged.push(id);
|
||
updateForm('members', merged);
|
||
}}
|
||
/>
|
||
{discardDialog}
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||
{header}
|
||
|
||
<KeyboardAvoidingView
|
||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||
style={{ flex: 1 }}
|
||
>
|
||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||
{/* Photo + Name preview */}
|
||
<View style={styles.photoPanel}>
|
||
<Pressable
|
||
onPress={() => { void pickPhoto(); }}
|
||
style={styles.photoBtn}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('contacts.form.upload_photo', 'Upload photo')}
|
||
>
|
||
{previewPhoto ? (
|
||
<ContactPhoto photo={previewPhoto} style={styles.photoThumb} fallback={photoPlaceholder} />
|
||
) : photoPlaceholder}
|
||
</Pressable>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={styles.previewName} numberOfLines={1}>
|
||
{previewName || (form.isOrg
|
||
? t('contacts.form.new_organization', 'New organization')
|
||
: t('contacts.create_new', 'New Contact'))}
|
||
</Text>
|
||
<Text style={styles.previewHint}>{t('contacts.form.photo_hint_mobile', 'Tap photo to choose an image')}</Text>
|
||
{previewPhoto ? (
|
||
<Pressable
|
||
onPress={() => updateForm('photo', { kind: 'removed' })}
|
||
style={styles.removePhotoBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
>
|
||
<Text style={styles.removePhotoLabel}>{t('contacts.form.remove_photo', 'Remove photo')}</Text>
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
|
||
{addressBookSection}
|
||
|
||
{/* Identity */}
|
||
<Section icon={<User size={16} color={c.textMuted} />} label={t('contacts.form.section_identity', 'Name & Identity')}>
|
||
<Pills
|
||
value={form.isOrg ? 'org' : 'person'}
|
||
options={opts.kind}
|
||
onChange={(v) => {
|
||
const isOrg = v === 'org';
|
||
setDirty(true);
|
||
setForm((f) => ({
|
||
...f,
|
||
isOrg,
|
||
// An organization card needs a place to type its name.
|
||
orgs: isOrg && f.orgs.length === 0
|
||
? [{ name: '', department: '', jobTitle: '', role: '' }]
|
||
: f.orgs,
|
||
}));
|
||
}}
|
||
/>
|
||
{form.isOrg ? (
|
||
<Field label={t('contacts.form.organization', 'Organization')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
|
||
ref={orgRef}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => fullRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.orgs[0]?.name || ''}
|
||
onChangeText={(v) => {
|
||
const next = form.orgs.length > 0 ? [...form.orgs] : [{ name: '', department: '', jobTitle: '', role: '' }];
|
||
next[0] = { ...next[0], name: v };
|
||
updateForm('orgs', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
) : (
|
||
<>
|
||
<View style={styles.row2}>
|
||
<Field label={t('contacts.form.prefix', 'Prefix')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.prefix_placeholder', 'Dr., Mr., Mrs.')}
|
||
ref={prefixRef}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => suffixRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.prefix}
|
||
onChangeText={(v) => updateForm('prefix', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.suffix', 'Suffix')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.suffix_placeholder', 'Jr., Sr., III')}
|
||
ref={suffixRef}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => givenRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.suffix}
|
||
onChangeText={(v) => updateForm('suffix', v)}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
<Field label={t('contacts.form.given_name', 'First name')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.given_name', 'First name')}
|
||
ref={givenRef}
|
||
testID="contact-first-name"
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => middleRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.given}
|
||
onChangeText={(v) => updateForm('given', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.middle_name', 'Middle name')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.middle_name', 'Middle name')}
|
||
ref={middleRef}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => surnameRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.middle}
|
||
onChangeText={(v) => updateForm('middle', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.surname', 'Last name')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.surname', 'Last name')}
|
||
ref={surnameRef}
|
||
testID="contact-last-name"
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => fullRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.surname}
|
||
onChangeText={(v) => updateForm('surname', v)}
|
||
/>
|
||
</Field>
|
||
{form.nicknames.map((nick, i) => (
|
||
<Field key={i} label={i === 0 ? t('contacts.form.nickname', 'Nickname') : undefined}>
|
||
<View style={styles.dateInputRow}>
|
||
<TextInput
|
||
style={[styles.input, { flex: 1 }]}
|
||
placeholder={t('contacts.form.nickname_placeholder', 'Nickname')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={nick}
|
||
onChangeText={(v) => {
|
||
const next = [...form.nicknames];
|
||
next[i] = v;
|
||
updateForm('nicknames', next);
|
||
}}
|
||
/>
|
||
{form.nicknames.length > 1 && (
|
||
<Pressable
|
||
onPress={() => updateForm('nicknames', form.nicknames.filter((_, idx) => idx !== i))}
|
||
hitSlop={8}
|
||
style={styles.removeBtn}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.remove', 'Remove')}
|
||
>
|
||
<XIcon size={14} color={c.textMuted} />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
</Field>
|
||
))}
|
||
{form.nicknames.every((n) => n.trim()) && (
|
||
<AddButton label={t('contacts.form.add_nickname', 'Add nickname')} onPress={() => updateForm('nicknames', [...form.nicknames, ''])} />
|
||
)}
|
||
</>
|
||
)}
|
||
<Field label={t('contacts.form.display_name_optional', 'Display name (optional)')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.display_name_placeholder', 'Custom display name')}
|
||
ref={fullRef}
|
||
returnKeyType="next"
|
||
blurOnSubmit={false}
|
||
onSubmitEditing={() => firstEmailRef.current?.focus()}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.full}
|
||
onChangeText={(v) => updateForm('full', v)}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
|
||
{/* Email */}
|
||
<Section icon={<Mail size={16} color={c.textMuted} />} label={t('contacts.form.email', 'Email')}>
|
||
{form.emails.map((e, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('emails', form.emails.filter((_, idx) => idx !== i))}
|
||
>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
|
||
ref={i === 0 ? firstEmailRef : undefined}
|
||
testID={`contact-email-${i}`}
|
||
placeholderTextColor={c.textMuted}
|
||
keyboardType="email-address"
|
||
autoCapitalize="none"
|
||
value={e.address}
|
||
onChangeText={(v) => {
|
||
const next = [...form.emails];
|
||
next[i] = { ...e, address: v };
|
||
updateForm('emails', next);
|
||
}}
|
||
/>
|
||
<Pills
|
||
value={e.context}
|
||
options={opts.contexts}
|
||
onChange={(v) => {
|
||
const next = [...form.emails];
|
||
next[i] = { ...e, context: v };
|
||
updateForm('emails', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_email', 'Add email')}
|
||
onPress={() => updateForm('emails', [...form.emails, { address: '', context: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Phone */}
|
||
<Section icon={<Phone size={16} color={c.textMuted} />} label={t('contacts.form.phone', 'Phone')}>
|
||
{form.phones.map((p, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('phones', form.phones.filter((_, idx) => idx !== i))}
|
||
>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.phone_placeholder', '+1 234 567 890')}
|
||
placeholderTextColor={c.textMuted}
|
||
keyboardType="phone-pad"
|
||
value={p.number}
|
||
onChangeText={(v) => {
|
||
const next = [...form.phones];
|
||
next[i] = { ...p, number: v };
|
||
updateForm('phones', next);
|
||
}}
|
||
/>
|
||
<Pills
|
||
value={p.feature}
|
||
options={opts.phoneFeatures}
|
||
onChange={(v) => {
|
||
const next = [...form.phones];
|
||
next[i] = { ...p, feature: v };
|
||
updateForm('phones', next);
|
||
}}
|
||
/>
|
||
<Pills
|
||
value={p.context}
|
||
options={opts.contexts}
|
||
onChange={(v) => {
|
||
const next = [...form.phones];
|
||
next[i] = { ...p, context: v };
|
||
updateForm('phones', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_phone', 'Add phone')}
|
||
onPress={() => updateForm('phones', [...form.phones, { number: '', context: '', feature: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Work */}
|
||
<Section
|
||
icon={<Building size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.section_work', 'Work & Organization')}
|
||
collapsible
|
||
defaultOpen={form.orgs.length > 0}
|
||
>
|
||
{form.orgs.map((o, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('orgs', form.orgs.filter((_, idx) => idx !== i))}
|
||
>
|
||
{!(form.isOrg && i === 0) && (
|
||
<Field label={t('contacts.form.organization', 'Organization')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={o.name}
|
||
onChangeText={(v) => {
|
||
const next = [...form.orgs];
|
||
next[i] = { ...o, name: v };
|
||
updateForm('orgs', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
)}
|
||
<Field label={t('contacts.form.department', 'Department')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.department_placeholder', 'Department')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={o.department}
|
||
onChangeText={(v) => {
|
||
const next = [...form.orgs];
|
||
next[i] = { ...o, department: v };
|
||
updateForm('orgs', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.job_title', 'Job title')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.job_title_placeholder', 'e.g., Software Engineer')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={o.jobTitle}
|
||
onChangeText={(v) => {
|
||
const next = [...form.orgs];
|
||
next[i] = { ...o, jobTitle: v };
|
||
updateForm('orgs', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.role', 'Role')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.role_placeholder', 'e.g., Team Lead')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={o.role}
|
||
onChangeText={(v) => {
|
||
const next = [...form.orgs];
|
||
next[i] = { ...o, role: v };
|
||
updateForm('orgs', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_organization', 'Add organization')}
|
||
onPress={() => updateForm('orgs', [...form.orgs, { name: '', department: '', jobTitle: '', role: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Address */}
|
||
<Section
|
||
icon={<MapPin size={16} color={c.textMuted} />}
|
||
label={t('contacts.detail.address_default_label', 'Address')}
|
||
collapsible
|
||
defaultOpen={form.addresses.length > 0}
|
||
>
|
||
{form.addresses.map((a, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('addresses', form.addresses.filter((_, idx) => idx !== i))}
|
||
>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.street', 'Street')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.street}
|
||
onChangeText={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, street: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
<View style={styles.row2}>
|
||
<Field>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.city', 'City')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.locality}
|
||
onChangeText={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, locality: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.region', 'State / Region')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.region}
|
||
onChangeText={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, region: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
<View style={styles.row2}>
|
||
<Field>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.postcode', 'Postal code')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.postcode}
|
||
onChangeText={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, postcode: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.country', 'Country')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.country}
|
||
onChangeText={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, country: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
<Pills
|
||
value={a.context}
|
||
options={opts.contexts}
|
||
onChange={(v) => {
|
||
const next = [...form.addresses];
|
||
next[i] = { ...a, context: v };
|
||
updateForm('addresses', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_address', 'Add address')}
|
||
onPress={() =>
|
||
updateForm('addresses', [...form.addresses, {
|
||
street: '', locality: '', region: '', postcode: '', country: '', context: '',
|
||
}])
|
||
}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Online services */}
|
||
<Section
|
||
icon={<Globe size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.online_services', 'Online Services')}
|
||
collapsible
|
||
defaultOpen={form.online.length > 0}
|
||
>
|
||
{form.online.map((s, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('online', form.online.filter((_, idx) => idx !== i))}
|
||
>
|
||
<Field label={t('contacts.form.url', 'URL')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoCapitalize="none"
|
||
value={s.uri}
|
||
onChangeText={(v) => {
|
||
const next = [...form.online];
|
||
next[i] = { ...s, uri: v };
|
||
updateForm('online', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<View style={styles.row2}>
|
||
<Field label={t('contacts.form.service_placeholder', 'Service')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder="LinkedIn, Mastodon, …"
|
||
placeholderTextColor={c.textMuted}
|
||
value={s.service}
|
||
onChangeText={(v) => {
|
||
const next = [...form.online];
|
||
next[i] = { ...s, service: v };
|
||
updateForm('online', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.label', 'Label')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.label_placeholder', 'Work, Personal, …')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={s.label}
|
||
onChangeText={(v) => {
|
||
const next = [...form.online];
|
||
next[i] = { ...s, label: v };
|
||
updateForm('online', next);
|
||
}}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_online_service', 'Add online service')}
|
||
onPress={() => updateForm('online', [...form.online, { uri: '', service: '', label: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Anniversaries */}
|
||
<Section
|
||
icon={<Cake size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.anniversaries', 'Anniversaries')}
|
||
collapsible
|
||
defaultOpen={form.anniversaries.length > 0}
|
||
>
|
||
{form.anniversaries.map((a, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('anniversaries', form.anniversaries.filter((_, idx) => idx !== i))}
|
||
>
|
||
<View style={styles.dateInputRow}>
|
||
<TextInput
|
||
style={[
|
||
styles.input,
|
||
{ flex: 1 },
|
||
a.date.trim() && !stringToPartialDate(a.date) ? styles.inputInvalid : null,
|
||
]}
|
||
placeholder={t('contacts.form.date_placeholder', 'YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={a.date}
|
||
onChangeText={(v) => {
|
||
const next = [...form.anniversaries];
|
||
next[i] = { ...a, date: v };
|
||
updateForm('anniversaries', next);
|
||
}}
|
||
/>
|
||
<Pressable
|
||
onPress={() => setDatePickerIndex(i)}
|
||
style={styles.datePickerBtn}
|
||
hitSlop={8}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('contacts.form.pick_date', 'Pick a date')}
|
||
>
|
||
<Calendar size={18} color={c.primary} />
|
||
</Pressable>
|
||
</View>
|
||
<Pills
|
||
value={a.kind}
|
||
options={opts.anniversaryKinds}
|
||
onChange={(v) => {
|
||
const next = [...form.anniversaries];
|
||
next[i] = { ...a, kind: v };
|
||
updateForm('anniversaries', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_anniversary', 'Add date')}
|
||
onPress={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Personal info */}
|
||
<Section
|
||
icon={<Heart size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.personal_info', 'Personal Info')}
|
||
collapsible
|
||
defaultOpen={form.personalInfo.length > 0}
|
||
>
|
||
{form.personalInfo.map((pi, i) => (
|
||
<RemovableRow
|
||
key={i}
|
||
onRemove={() => updateForm('personalInfo', form.personalInfo.filter((_, idx) => idx !== i))}
|
||
>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.personal_info_placeholder', 'e.g., Photography')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={pi.value}
|
||
onChangeText={(v) => {
|
||
const next = [...form.personalInfo];
|
||
next[i] = { ...pi, value: v };
|
||
updateForm('personalInfo', next);
|
||
}}
|
||
/>
|
||
<Pills
|
||
value={pi.kind}
|
||
options={opts.personalInfoKinds}
|
||
onChange={(v) => {
|
||
const next = [...form.personalInfo];
|
||
next[i] = { ...pi, kind: v };
|
||
updateForm('personalInfo', next);
|
||
}}
|
||
/>
|
||
<Pills
|
||
value={pi.level}
|
||
options={opts.personalInfoLevels}
|
||
onChange={(v) => {
|
||
const next = [...form.personalInfo];
|
||
next[i] = { ...pi, level: v };
|
||
updateForm('personalInfo', next);
|
||
}}
|
||
/>
|
||
</RemovableRow>
|
||
))}
|
||
<AddButton
|
||
label={t('contacts.form.add_personal_info', 'Add entry')}
|
||
onPress={() => updateForm('personalInfo', [...form.personalInfo, { kind: 'hobby', level: '', value: '' }])}
|
||
/>
|
||
</Section>
|
||
|
||
{/* Gender */}
|
||
{!form.isOrg && (
|
||
<Section
|
||
icon={<UserCircle size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.gender', 'Gender')}
|
||
collapsible
|
||
defaultOpen={!!(form.grammaticalGender || form.pronouns)}
|
||
>
|
||
<Field label={t('contacts.form.grammatical_gender', 'Grammatical gender')}>
|
||
<Pills
|
||
value={form.grammaticalGender}
|
||
options={
|
||
// Values from other clients (RFC 9554 GRAMGENDER: common,
|
||
// neuter, animate, inanimate) stay selectable so a save
|
||
// never silently drops them.
|
||
form.grammaticalGender && !opts.gender.some((o) => o.value === form.grammaticalGender)
|
||
? [...opts.gender, { value: form.grammaticalGender, label: form.grammaticalGender }]
|
||
: opts.gender
|
||
}
|
||
onChange={(v) => updateForm('grammaticalGender', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.pronouns', 'Pronouns')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.pronouns_placeholder', 'they/them')}
|
||
placeholderTextColor={c.textMuted}
|
||
value={form.pronouns}
|
||
onChangeText={(v) => updateForm('pronouns', v)}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Calendar */}
|
||
<Section
|
||
icon={<Calendar size={16} color={c.textMuted} />}
|
||
label={t('contacts.form.calendar', 'Calendar')}
|
||
collapsible
|
||
defaultOpen={!!(form.calendarUri || form.schedulingUri || form.freeBusyUri)}
|
||
>
|
||
<Field label={t('contacts.form.calendar_uri', 'Calendar URL')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoCapitalize="none"
|
||
value={form.calendarUri}
|
||
onChangeText={(v) => updateForm('calendarUri', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.scheduling_uri', 'Scheduling URL')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoCapitalize="none"
|
||
value={form.schedulingUri}
|
||
onChangeText={(v) => updateForm('schedulingUri', v)}
|
||
/>
|
||
</Field>
|
||
<Field label={t('contacts.form.freebusy_uri', 'Free/Busy URL')}>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||
placeholderTextColor={c.textMuted}
|
||
autoCapitalize="none"
|
||
value={form.freeBusyUri}
|
||
onChangeText={(v) => updateForm('freeBusyUri', v)}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
|
||
{categoriesSection}
|
||
{notesSection}
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
|
||
{discardDialog}
|
||
|
||
{datePickerIndex !== null && (() => {
|
||
const draft = form.anniversaries[datePickerIndex];
|
||
const parsed = draft ? parseDateDraft(draft.date) : new Date();
|
||
const onChange = (event: DateTimePickerEvent, selected?: Date) => {
|
||
if (Platform.OS === 'android') {
|
||
setDatePickerIndex(null);
|
||
}
|
||
if (event.type === 'dismissed' || !selected) return;
|
||
const iso = formatDateAsISO(selected);
|
||
const next = [...form.anniversaries];
|
||
if (next[datePickerIndex]) {
|
||
next[datePickerIndex] = { ...next[datePickerIndex], date: iso };
|
||
updateForm('anniversaries', next);
|
||
}
|
||
};
|
||
if (Platform.OS === 'ios') {
|
||
return (
|
||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
|
||
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerIndex(null)} />
|
||
<View style={styles.pickerSheet}>
|
||
<View style={styles.pickerHeader}>
|
||
<Pressable onPress={() => setDatePickerIndex(null)} hitSlop={8}>
|
||
<Text style={styles.pickerDone}>{t('common.done', 'Done')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
<DateTimePicker
|
||
value={parsed}
|
||
mode="date"
|
||
display="spinner"
|
||
onChange={onChange}
|
||
/>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
return (
|
||
<DateTimePicker
|
||
value={parsed}
|
||
mode="date"
|
||
display="default"
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
})()}
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function parseDateDraft(s: string): Date {
|
||
const pd = stringToPartialDate(s);
|
||
const now = new Date();
|
||
if (!pd) return now;
|
||
return new Date(pd.year ?? now.getFullYear(), (pd.month ?? 1) - 1, pd.day ?? 1);
|
||
}
|
||
|
||
function formatDateAsISO(d: Date): string {
|
||
const y = String(d.getFullYear()).padStart(4, '0');
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const da = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${da}`;
|
||
}
|
||
|
||
function makeStyles(c: ThemePalette) {
|
||
return StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: c.background },
|
||
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||
missingText: { ...typography.body, color: c.textMuted },
|
||
scrollContent: {
|
||
paddingVertical: spacing.md,
|
||
paddingBottom: spacing.xxxl * 2,
|
||
paddingHorizontal: spacing.lg,
|
||
gap: spacing.lg,
|
||
},
|
||
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: spacing.sm,
|
||
gap: spacing.xs,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.borderLight,
|
||
},
|
||
headerBtn: {
|
||
width: 40, height: 40,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
borderRadius: radius.full,
|
||
},
|
||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||
saveBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.xs,
|
||
paddingHorizontal: spacing.md,
|
||
height: 36,
|
||
backgroundColor: c.primary,
|
||
borderRadius: radius.full,
|
||
},
|
||
saveLabel: { ...typography.bodyMedium, color: c.primaryForeground },
|
||
|
||
photoPanel: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
},
|
||
photoBtn: { borderRadius: 999 },
|
||
photoThumb: {
|
||
width: 80, height: 80,
|
||
borderRadius: 40,
|
||
backgroundColor: c.surface,
|
||
},
|
||
photoPlaceholder: {
|
||
alignItems: 'center', justifyContent: 'center',
|
||
borderWidth: 1, borderColor: c.border, borderStyle: 'dashed',
|
||
},
|
||
previewName: { ...typography.h3, color: c.text },
|
||
previewHint: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||
removePhotoBtn: { marginTop: spacing.xs },
|
||
removePhotoLabel: { ...typography.caption, color: c.error },
|
||
|
||
section: { gap: spacing.sm },
|
||
sectionHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.sm,
|
||
},
|
||
sectionIcon: { width: 16 },
|
||
sectionLabel: {
|
||
flex: 1,
|
||
...typography.bodyMedium,
|
||
color: c.textSecondary,
|
||
textTransform: 'uppercase',
|
||
fontSize: 11,
|
||
letterSpacing: 0.6,
|
||
},
|
||
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
|
||
|
||
field: { gap: 4 },
|
||
fieldLabel: { ...typography.caption, color: c.mutedForeground },
|
||
row2: { flexDirection: 'row', gap: spacing.sm },
|
||
|
||
input: {
|
||
minHeight: componentSizes.inputHeight,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
borderRadius: radius.sm,
|
||
// The shared Input: transparent, bordered (webmail contact form).
|
||
backgroundColor: 'transparent',
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
...typography.body,
|
||
color: c.text,
|
||
},
|
||
inputInvalid: { borderColor: c.error },
|
||
multiline: {
|
||
minHeight: 80,
|
||
paddingVertical: spacing.sm,
|
||
textAlignVertical: 'top',
|
||
},
|
||
|
||
pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||
pill: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.surface,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
},
|
||
pillActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||
pillText: { ...typography.caption, color: c.textSecondary },
|
||
pillTextActive: { color: c.primaryForeground },
|
||
|
||
removableRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
gap: spacing.xs,
|
||
paddingVertical: spacing.xs,
|
||
borderTopWidth: 1,
|
||
borderTopColor: c.borderLight,
|
||
paddingTop: spacing.sm,
|
||
},
|
||
removeBtn: {
|
||
width: 26, height: 26,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.surface,
|
||
marginTop: 6,
|
||
},
|
||
|
||
memberName: { ...typography.body, color: c.text, marginTop: 6 },
|
||
memberEmail: { ...typography.caption, color: c.textMuted },
|
||
|
||
addBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
alignSelf: 'flex-start',
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 6,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.primaryBg,
|
||
},
|
||
addBtnPressed: { backgroundColor: c.surfaceHover },
|
||
addBtnLabel: { ...typography.caption, color: c.primary },
|
||
|
||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||
keywordChip: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.primaryBg,
|
||
},
|
||
keywordChipText: { ...typography.caption, color: c.primary },
|
||
suggestedChip: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.surface,
|
||
borderWidth: 1,
|
||
borderColor: c.border,
|
||
},
|
||
suggestedChipText: { ...typography.caption, color: c.textSecondary },
|
||
|
||
dateInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||
datePickerBtn: {
|
||
width: 40, height: 40,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
borderRadius: radius.full,
|
||
backgroundColor: c.primaryBg,
|
||
},
|
||
|
||
pickerOverlay: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||
},
|
||
pickerSheet: {
|
||
position: 'absolute',
|
||
left: 0, right: 0, bottom: 0,
|
||
backgroundColor: c.background,
|
||
borderTopLeftRadius: radius.lg,
|
||
borderTopRightRadius: radius.lg,
|
||
paddingBottom: spacing.lg,
|
||
},
|
||
pickerHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-end',
|
||
padding: spacing.md,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.borderLight,
|
||
},
|
||
pickerDone: { ...typography.bodySemibold, color: c.primary },
|
||
});
|
||
}
|