feat: add contacts to groups from the detail sheet, dedupe group recipients and drop the UID when duplicating

This commit is contained in:
Linus Rath
2026-08-29 17:59:11 +02:00
parent 9800ad07c2
commit 1cef53af5a
2 changed files with 48 additions and 16 deletions
+43 -3
View File
@@ -12,7 +12,7 @@ import {
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin, ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser, Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages, Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
Clock, KeyRound, FolderInput, Clock, KeyRound, FolderInput, Plus,
} from 'lucide-react-native'; } from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types'; import type { ContactCard } from '../api/types';
@@ -86,10 +86,13 @@ export default function ContactDetailScreen() {
const deleteContact = useContactsStore((s) => s.deleteContact); const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact); const createContact = useContactsStore((s) => s.createContact);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook); 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 [confirmDelete, setConfirmDelete] = React.useState(false);
const [moreOpen, setMoreOpen] = React.useState(false); const [moreOpen, setMoreOpen] = React.useState(false);
const [moveOpen, setMoveOpen] = React.useState(false); const [moveOpen, setMoveOpen] = React.useState(false);
const [groupPickerOpen, setGroupPickerOpen] = React.useState(false);
if (!contact) { if (!contact) {
return ( return (
@@ -108,7 +111,9 @@ export default function ContactDetailScreen() {
} }
const name = getContactDisplayName(contact) || 'Unnamed'; 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 email = getContactPrimaryEmail(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : ''; const phone = contact.phones ? Object.values(contact.phones)[0]?.number : '';
const photoUri = getContactPhotoUri(contact); const photoUri = getContactPhotoUri(contact);
@@ -133,7 +138,8 @@ export default function ContactDetailScreen() {
const bookNames = bookIds const bookNames = bookIds
.map((id) => addressBooks.find((b) => b.id === id)?.name) .map((id) => addressBooks.find((b) => b.id === id)?.name)
.filter(Boolean) as string[]; .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 hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const firstPronoun = contact.speakToAs?.pronouns const firstPronoun = contact.speakToAs?.pronouns
? Object.values(contact.speakToAs.pronouns)[0]?.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.'); Alert.alert('No address book', 'Cannot duplicate without an address book.');
return; 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 { const {
id: _id, id: _id,
uid: _uid,
originalId: _originalId,
accountId: _accountId,
accountName: _accountName,
isShared: _isShared,
addressBookIds: _abIds, addressBookIds: _abIds,
created: _created, created: _created,
updated: _updated, updated: _updated,
@@ -250,6 +263,11 @@ export default function ContactDetailScreen() {
label: 'Duplicate', label: 'Duplicate',
onPress: () => { void doDuplicate(); }, onPress: () => { void doDuplicate(); },
}, },
!isGroup(contact) && {
icon: <Users size={16} color={c.text} />,
label: 'Add to group',
onPress: () => setGroupPickerOpen(true),
},
addressBooks.length > 0 && { addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />, icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book', label: 'Move to address book',
@@ -632,6 +650,28 @@ export default function ContactDetailScreen() {
onClose={() => setMoreOpen(false)} onClose={() => setMoreOpen(false)}
/> />
<MoreActionsSheet
visible={groupPickerOpen}
onClose={() => setGroupPickerOpen(false)}
items={[
...groups.map((g): MoreItem => ({
icon: <Users size={16} color={c.text} />,
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: <Plus size={16} color={c.text} />,
label: 'New group…',
onPress: () => navigation.navigate('ContactForm', { asGroup: true, memberIds: [contact.id] }),
},
]}
/>
<AddressBookPickerSheet <AddressBookPickerSheet
visible={moveOpen} visible={moveOpen}
onClose={() => setMoveOpen(false)} onClose={() => setMoveOpen(false)}
+5 -13
View File
@@ -38,6 +38,8 @@ export default function GroupDetailScreen() {
const allContacts = useContactsStore((s) => s.contacts); const allContacts = useContactsStore((s) => s.contacts);
const updateContact = useContactsStore((s) => s.updateContact); const updateContact = useContactsStore((s) => s.updateContact);
const deleteContact = useContactsStore((s) => s.deleteContact); const deleteContact = useContactsStore((s) => s.deleteContact);
const addContactsToGroup = useContactsStore((s) => s.addContactsToGroup);
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
const group = React.useMemo( const group = React.useMemo(
() => allContacts.find((c) => c.id === groupId), () => allContacts.find((c) => c.id === groupId),
[allContacts, groupId], [allContacts, groupId],
@@ -70,12 +72,8 @@ export default function GroupDetailScreen() {
const name = getContactDisplayName(group) || 'Group'; const name = getContactDisplayName(group) || 'Group';
const emailAll = () => { const emailAll = () => {
const recipients: EmailAddress[] = []; // One recipient per address: two members sharing a mailbox are sent once.
for (const m of members) { const recipients: EmailAddress[] = getGroupRecipients(group.id);
const email = getContactPrimaryEmail(m);
if (!email) continue;
recipients.push({ name: getContactDisplayName(m) || '', email });
}
if (recipients.length === 0) { if (recipients.length === 0) {
Alert.alert('No emails', 'None of the members have an email address.'); Alert.alert('No emails', 'None of the members have an email address.');
return; return;
@@ -84,14 +82,8 @@ export default function GroupDetailScreen() {
}; };
const addMembers = async (ids: string[]) => { 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 { try {
await updateContact(group.id, { members: existing }); await addContactsToGroup(group.id, ids);
} catch (err) { } catch (err) {
Alert.alert('Failed to add members', err instanceof Error ? err.message : 'Unknown error'); Alert.alert('Failed to add members', err instanceof Error ? err.message : 'Unknown error');
} }