diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index 6c239b01..7e0e1e7a 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -12,7 +12,7 @@ import { ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin, Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser, Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages, - Clock, KeyRound, FolderInput, + Clock, KeyRound, FolderInput, Plus, } from 'lucide-react-native'; import type { RootStackParamList } from '../navigation/types'; import type { ContactCard } from '../api/types'; @@ -86,10 +86,13 @@ export default function ContactDetailScreen() { const deleteContact = useContactsStore((s) => s.deleteContact); const createContact = useContactsStore((s) => s.createContact); const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook); + const addContactsToGroup = useContactsStore((s) => s.addContactsToGroup); + const groups = React.useMemo(() => allContacts.filter(isGroup), [allContacts]); const [confirmDelete, setConfirmDelete] = React.useState(false); const [moreOpen, setMoreOpen] = React.useState(false); const [moveOpen, setMoveOpen] = React.useState(false); + const [groupPickerOpen, setGroupPickerOpen] = React.useState(false); if (!contact) { return ( @@ -108,7 +111,9 @@ export default function ContactDetailScreen() { } const name = getContactDisplayName(contact) || 'Unnamed'; - const nickname = getPrimaryNickname(contact); + const nickname = contact.nicknames + ? Object.values(contact.nicknames).map((n) => n.name).filter(Boolean).join(', ') + : getPrimaryNickname(contact); const email = getContactPrimaryEmail(contact); const phone = contact.phones ? Object.values(contact.phones)[0]?.number : ''; const photoUri = getContactPhotoUri(contact); @@ -133,7 +138,8 @@ export default function ContactDetailScreen() { const bookNames = bookIds .map((id) => addressBooks.find((b) => b.id === id)?.name) .filter(Boolean) as string[]; - const subtitleParts = [jobTitles[0]?.name, org].filter(Boolean) as string[]; + // On an organization card the org name is already the heading; don't repeat it. + const subtitleParts = [jobTitles[0]?.name, org === name ? undefined : org].filter(Boolean) as string[]; const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns)); const firstPronoun = contact.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns @@ -213,8 +219,15 @@ export default function ContactDetailScreen() { Alert.alert('No address book', 'Cannot duplicate without an address book.'); return; } + // Drop the UID too: the copy gets a fresh one on create, otherwise group + // membership by uid matches both cards and CardDAV sees two cards with one UID. const { id: _id, + uid: _uid, + originalId: _originalId, + accountId: _accountId, + accountName: _accountName, + isShared: _isShared, addressBookIds: _abIds, created: _created, updated: _updated, @@ -250,6 +263,11 @@ export default function ContactDetailScreen() { label: 'Duplicate', onPress: () => { void doDuplicate(); }, }, + !isGroup(contact) && { + icon: , + label: 'Add to group', + onPress: () => setGroupPickerOpen(true), + }, addressBooks.length > 0 && { icon: , label: 'Move to address book', @@ -632,6 +650,28 @@ export default function ContactDetailScreen() { onClose={() => setMoreOpen(false)} /> + setGroupPickerOpen(false)} + items={[ + ...groups.map((g): MoreItem => ({ + icon: , + label: getContactDisplayName(g) || 'Group', + onPress: () => { + addContactsToGroup(g.id, [contact.id]).catch((err) => { + Alert.alert('Add to group failed', err instanceof Error ? err.message : 'Unknown error'); + }); + }, + })), + ...(groups.length > 0 ? [{ separator: true } as MoreItem] : []), + { + icon: , + label: 'New group…', + onPress: () => navigation.navigate('ContactForm', { asGroup: true, memberIds: [contact.id] }), + }, + ]} + /> + setMoveOpen(false)} diff --git a/src/screens/GroupDetailScreen.tsx b/src/screens/GroupDetailScreen.tsx index a39a940e..a5cecbb5 100644 --- a/src/screens/GroupDetailScreen.tsx +++ b/src/screens/GroupDetailScreen.tsx @@ -38,6 +38,8 @@ export default function GroupDetailScreen() { const allContacts = useContactsStore((s) => s.contacts); const updateContact = useContactsStore((s) => s.updateContact); const deleteContact = useContactsStore((s) => s.deleteContact); + const addContactsToGroup = useContactsStore((s) => s.addContactsToGroup); + const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients); const group = React.useMemo( () => allContacts.find((c) => c.id === groupId), [allContacts, groupId], @@ -70,12 +72,8 @@ export default function GroupDetailScreen() { const name = getContactDisplayName(group) || 'Group'; const emailAll = () => { - const recipients: EmailAddress[] = []; - for (const m of members) { - const email = getContactPrimaryEmail(m); - if (!email) continue; - recipients.push({ name: getContactDisplayName(m) || '', email }); - } + // One recipient per address: two members sharing a mailbox are sent once. + const recipients: EmailAddress[] = getGroupRecipients(group.id); if (recipients.length === 0) { Alert.alert('No emails', 'None of the members have an email address.'); return; @@ -84,14 +82,8 @@ export default function GroupDetailScreen() { }; const addMembers = async (ids: string[]) => { - const existing = group.members ? { ...group.members } : {}; - for (const id of ids) { - const contact = allContacts.find((c) => c.id === id); - const key = contact?.uid || id; - existing[key] = true; - } try { - await updateContact(group.id, { members: existing }); + await addContactsToGroup(group.id, ids); } catch (err) { Alert.alert('Failed to add members', err instanceof Error ? err.message : 'Unknown error'); }