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');
}