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,
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: <Users size={16} color={c.text} />,
label: 'Add to group',
onPress: () => setGroupPickerOpen(true),
},
addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book',
@@ -632,6 +650,28 @@ export default function ContactDetailScreen() {
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
visible={moveOpen}
onClose={() => setMoveOpen(false)}
+5 -13
View File
@@ -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');
}