mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: add contacts to groups from the detail sheet, dedupe group recipients and drop the UID when duplicating
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user