From 5accf0a86cc4eb6234631647664520aa906c327d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:40:32 +0100 Subject: [PATCH] feat: enhance contact group management with uid normalization and improved member handling --- app/[locale]/contacts/page.tsx | 11 ++- app/api/dev-jmap/[...path]/route.ts | 101 ++++++++++++++++++++++------ locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fr/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/nl/common.json | 3 + locales/pt/common.json | 3 + stores/contact-store.ts | 49 ++++++++++++-- 11 files changed, 153 insertions(+), 32 deletions(-) diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index a5b15c04..718c47cb 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -218,11 +218,10 @@ export default function ContactsPage() { const jmapClient = supportsSync && client ? client : null; if (view === "group-edit" && selectedGroup) { await updateGroup(jmapClient, selectedGroup.id, name); - const currentMemberIds = selectedGroup.members - ? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k]) - : []; - const toAdd = memberIds.filter(id => !currentMemberIds.includes(id)); - const toRemove = currentMemberIds.filter(id => !memberIds.includes(id)); + // Use resolved member contact IDs for diff, not raw urn:uuid: keys + const currentIds = selectedGroupMembers.map(m => m.id); + const toAdd = memberIds.filter(id => !currentIds.includes(id)); + const toRemove = currentIds.filter(id => !memberIds.includes(id)); if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd); if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove); toast.success(t("toast.updated")); @@ -232,7 +231,7 @@ export default function ContactsPage() { toast.success(t("toast.created")); setView("list"); } - }, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); + }, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); const handleRemoveGroupMember = async (memberId: string) => { if (!selectedGroup) return; diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index d44f2c55..4bf44859 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -746,7 +746,7 @@ const addressBooks = [ const contacts = [ // --- Personal address book --- - { id: 'contact-001', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Sophie' }, { kind: 'surname', value: 'Müller' }] }, emails: { e1: { address: 'sophie@eurotech.example' } }, phones: { p1: { number: '+49 30 8844 2200' } }, @@ -754,7 +754,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } }, }, - { id: 'contact-002', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] }, emails: { e1: { address: 'pierre@dubois.example' } }, phones: { p1: { number: '+33 1 42 68 53 00' } }, @@ -762,7 +762,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, }, - { id: 'contact-003', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] }, emails: { e1: { address: 'chiara@rossi.example' } }, phones: { p1: { number: '+39 02 7634 5678' } }, @@ -770,14 +770,14 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, }, - { id: 'contact-004', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] }, emails: { e1: { address: 'karel@devries.example' } }, phones: { p1: { number: '+31 20 555 0142' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, notes: { n1: { note: 'Backend developer. Cycles to work rain or shine — true Dutchman.' } }, }, - { id: 'contact-005', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] }, emails: { e1: { address: 'lars.johansson@fjord-systems.example' } }, phones: { p1: { number: '+46 8 123 456 78' } }, @@ -785,7 +785,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } }, }, - { id: 'contact-006', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] }, emails: { e1: { address: 'elise.moreau@fjord-systems.example' } }, phones: { p1: { number: '+33 6 12 34 56 78' } }, @@ -793,14 +793,14 @@ const contacts = [ addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } }, }, - { id: 'contact-007', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] }, emails: { e1: { address: 'francesco@bianchi.example' } }, phones: { p1: { number: '+39 06 9876 5432' } }, addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } }, }, - { id: 'contact-008', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] }, emails: { e1: { address: 'astrid@berglabs.example' } }, phones: { p1: { number: '+31 70 362 4242' } }, @@ -808,7 +808,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } }, }, - { id: 'contact-009', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] }, emails: { e1: { address: 'henrik@nielsen-konsult.example' } }, phones: { p1: { number: '+45 33 42 42 42' } }, @@ -816,7 +816,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } }, }, - { id: 'contact-010', addressBookIds: { 'ab-1': true }, kind: 'individual', + { id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] }, emails: { e1: { address: 'isabelle.martin@sorbonne.example' } }, phones: { p1: { number: '+33 1 44 27 42 42' } }, @@ -825,7 +825,7 @@ const contacts = [ notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } }, }, // --- Work address book --- - { id: 'contact-011', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Jacques' }, { kind: 'surname', value: 'Lefèvre' }] }, emails: { e1: { address: 'jacques@lefevre-avocats.example' } }, phones: { p1: { number: '+33 1 53 67 42 00' } }, @@ -833,7 +833,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } }, }, - { id: 'contact-012', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] }, emails: { e1: { address: 'katrin.bauer@charite.example' } }, phones: { p1: { number: '+49 30 450 570 000' } }, @@ -841,7 +841,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } }, }, - { id: 'contact-013', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] }, emails: { e1: { address: 'liam.odonaill@finanz.example' } }, phones: { p1: { number: '+353 1 677 4242' } }, @@ -849,7 +849,7 @@ const contacts = [ addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } }, }, - { id: 'contact-014', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] }, emails: { e1: { address: 'maria@garcia-design.example' } }, phones: { p1: { number: '+34 91 420 4242' } }, @@ -857,7 +857,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } }, }, - { id: 'contact-015', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] }, emails: { e1: { address: 'nils@digitaal.example' } }, phones: { p1: { number: '+31 20 624 1337' } }, @@ -865,7 +865,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, }, - { id: 'contact-016', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] }, emails: { e1: { address: 'olivia@kowalska-marketing.example' } }, phones: { p1: { number: '+48 22 505 4242' } }, @@ -873,7 +873,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } }, }, - { id: 'contact-017', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] }, emails: { e1: { address: 'padraig@murphy-bau.example' } }, phones: { p1: { number: '+353 86 123 4242' } }, @@ -881,7 +881,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } }, }, - { id: 'contact-018', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] }, emails: { e1: { address: 'raquel@ferreira-media.example' } }, phones: { p1: { number: '+351 21 342 4242' } }, @@ -889,7 +889,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } }, }, - { id: 'contact-019', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] }, emails: { e1: { address: 'sebastien@dumont-conseil.example' } }, phones: { p1: { number: '+32 2 555 4242' } }, @@ -897,7 +897,7 @@ const contacts = [ addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } }, }, - { id: 'contact-020', addressBookIds: { 'ab-2': true }, kind: 'individual', + { id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] }, emails: { e1: { address: 'annika@lindgren.example' }, e2: { address: 'annika.personal@proton.example' } }, phones: { p1: { number: '+46 70 123 4242' } }, @@ -906,6 +906,22 @@ const contacts = [ nicknames: { n1: { name: 'Anni' } }, notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, }, + // --- Groups --- + { id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000001-0000-0000-0000-000000000001', + name: { components: [{ kind: 'given' as const, value: 'Fjord Systems Team' }], isOrdered: true }, + members: { 'urn:uuid:c0000005-0000-0000-0000-000000000005': true, 'urn:uuid:c0000006-0000-0000-0000-000000000006': true }, + }, + { id: 'contact-group-002', addressBookIds: { 'ab-1': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000002-0000-0000-0000-000000000002', + name: { components: [{ kind: 'given' as const, value: 'Design Friends' }], isOrdered: true }, + members: { 'urn:uuid:c0000003-0000-0000-0000-000000000003': true, 'urn:uuid:c0000007-0000-0000-0000-000000000007': true }, + }, + { id: 'contact-group-003', addressBookIds: { 'ab-2': true }, kind: 'group' as const, + uid: 'urn:uuid:g0000003-0000-0000-0000-000000000003', + name: { components: [{ kind: 'given' as const, value: 'Legal & Finance' }], isOrdered: true }, + members: { 'urn:uuid:c0000011-0000-0000-0000-000000000011': true, 'urn:uuid:c0000013-0000-0000-0000-000000000013': true }, + }, ]; // --------------------------------------------------------------------------- @@ -1602,7 +1618,50 @@ const METHOD_HANDLERS: Record Meth 'VacationResponse/get': handleVacationResponseGet, 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], 'ContactCard/get': handleContactCardGet, - 'ContactCard/set': (_args, callId) => ['ContactCard/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'ContactCard/set': (args, callId) => { + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + + if (args.create) { + for (const [tempId, data] of Object.entries(args.create as Record>)) { + const newId = `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newUid = `urn:uuid:${crypto.randomUUID()}`; + const newContact = { id: newId, uid: newUid, ...data, addressBookIds: data.addressBookIds || { 'ab-1': true } }; + contacts.push(newContact as typeof contacts[number]); + created[tempId] = { id: newId, uid: newUid }; + } + } + + if (args.update) { + for (const [id, patches] of Object.entries(args.update as Record>)) { + const idx = contacts.findIndex(c => c.id === id); + if (idx !== -1) { + contacts[idx] = { ...contacts[idx], ...patches } as typeof contacts[number]; + updated[id] = null; + } + } + } + + if (args.destroy) { + for (const id of args.destroy as string[]) { + const idx = contacts.findIndex(c => c.id === id); + if (idx !== -1) { + contacts.splice(idx, 1); + destroyed.push(id); + } + } + } + + return ['ContactCard/set', { + accountId: ACCOUNT_ID, + oldState: nextState(), + newState: nextState(), + created: Object.keys(created).length > 0 ? created : null, + updated: Object.keys(updated).length > 0 ? updated : null, + destroyed: destroyed.length > 0 ? destroyed : null, + }, callId]; + }, 'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: contacts.map(c => c.id), total: contacts.length, position: 0 }, callId], 'AddressBook/get': handleAddressBookGet, 'Calendar/get': handleCalendarGet, diff --git a/locales/de/common.json b/locales/de/common.json index 002b27bf..b49bb8da 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Versuchen Sie einen anderen Suchbegriff", "clear_search": "Suche löschen", "import_vcard": "vCard importieren", + "delete_confirm_title": "Kontakt löschen", "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?", "local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)", "back_to_mail": "Zurück zur E-Mail", @@ -1376,6 +1377,7 @@ "create": "Neue Gruppe", "edit": "Gruppe bearbeiten", "empty": "Keine Gruppen", + "delete_confirm_title": "Gruppe löschen", "delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?", "name_label": "Gruppenname", "name_placeholder": "z.B. Team, Familie", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}", "select_all": "Alle auswählen", "delete": "Löschen", + "delete_confirm_title": "Kontakte löschen", "delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?", "deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}", "add_to_group": "Zur Gruppe hinzufügen", diff --git a/locales/en/common.json b/locales/en/common.json index 7d695d82..7a0141be 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Try a different search term", "clear_search": "Clear search", "import_vcard": "Import vCard", + "delete_confirm_title": "Delete contact", "delete_confirm": "Are you sure you want to delete this contact?", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "back_to_mail": "Back to mail", @@ -1376,6 +1377,7 @@ "create": "New Group", "edit": "Edit Group", "empty": "No groups yet", + "delete_confirm_title": "Delete group", "delete_confirm": "Are you sure you want to delete this group?", "name_label": "Group Name", "name_placeholder": "e.g., Team, Family", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 selected} other {# selected}}", "select_all": "Select all", "delete": "Delete", + "delete_confirm_title": "Delete contacts", "delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?", "deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}", "add_to_group": "Add to group", diff --git a/locales/es/common.json b/locales/es/common.json index b5873bd6..8cb4941a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Prueba con otro término de búsqueda", "clear_search": "Borrar búsqueda", "import_vcard": "Importar vCard", + "delete_confirm_title": "Eliminar contacto", "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?", "local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)", "back_to_mail": "Volver al correo", @@ -1376,6 +1377,7 @@ "create": "Nuevo grupo", "edit": "Editar grupo", "empty": "No hay grupos", + "delete_confirm_title": "Eliminar grupo", "delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?", "name_label": "Nombre del grupo", "name_placeholder": "ej. Equipo, Familia", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}", "select_all": "Seleccionar todo", "delete": "Eliminar", + "delete_confirm_title": "Eliminar contactos", "delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?", "deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}", "add_to_group": "Agregar al grupo", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4e5e602d..b6198658 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Essayez un autre terme de recherche", "clear_search": "Effacer la recherche", "import_vcard": "Importer vCard", + "delete_confirm_title": "Supprimer le contact", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", "local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)", "back_to_mail": "Retour aux e-mails", @@ -1376,6 +1377,7 @@ "create": "Nouveau groupe", "edit": "Modifier le groupe", "empty": "Aucun groupe", + "delete_confirm_title": "Supprimer le groupe", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?", "name_label": "Nom du groupe", "name_placeholder": "ex. Équipe, Famille", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}", "select_all": "Tout sélectionner", "delete": "Supprimer", + "delete_confirm_title": "Supprimer les contacts", "delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?", "deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}", "add_to_group": "Ajouter au groupe", diff --git a/locales/it/common.json b/locales/it/common.json index d3df2d81..76bf97df 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Prova con un altro termine di ricerca", "clear_search": "Cancella ricerca", "import_vcard": "Importa vCard", + "delete_confirm_title": "Elimina contatto", "delete_confirm": "Sei sicuro di voler eliminare questo contatto?", "local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)", "back_to_mail": "Torna alla posta", @@ -1376,6 +1377,7 @@ "create": "Nuovo gruppo", "edit": "Modifica gruppo", "empty": "Nessun gruppo", + "delete_confirm_title": "Elimina gruppo", "delete_confirm": "Sei sicuro di voler eliminare questo gruppo?", "name_label": "Nome del gruppo", "name_placeholder": "es. Team, Famiglia", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 selezionato} other {# selezionati}}", "select_all": "Seleziona tutto", "delete": "Elimina", + "delete_confirm_title": "Elimina contatti", "delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?", "deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}", "add_to_group": "Aggiungi al gruppo", diff --git a/locales/ja/common.json b/locales/ja/common.json index a87b4454..7287112e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "別の検索語をお試しください", "clear_search": "検索をクリア", "import_vcard": "vCardをインポート", + "delete_confirm_title": "連絡先を削除", "delete_confirm": "この連絡先を削除してもよろしいですか?", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_mail": "メールに戻る", @@ -1376,6 +1377,7 @@ "create": "新しいグループ", "edit": "グループを編集", "empty": "グループがありません", + "delete_confirm_title": "グループを削除", "delete_confirm": "このグループを削除してもよろしいですか?", "name_label": "グループ名", "name_placeholder": "例:チーム、家族", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, other {#件選択中}}", "select_all": "すべて選択", "delete": "削除", + "delete_confirm_title": "連絡先を削除", "delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?", "deleted": "{count, plural, other {#件の連絡先を削除しました}}", "add_to_group": "グループに追加", diff --git a/locales/nl/common.json b/locales/nl/common.json index 093c3966..9c3d7972 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Probeer een andere zoekterm", "clear_search": "Zoekopdracht wissen", "import_vcard": "vCard importeren", + "delete_confirm_title": "Contact verwijderen", "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?", "local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)", "back_to_mail": "Terug naar e-mail", @@ -1376,6 +1377,7 @@ "create": "Nieuwe groep", "edit": "Groep bewerken", "empty": "Geen groepen", + "delete_confirm_title": "Groep verwijderen", "delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?", "name_label": "Groepsnaam", "name_placeholder": "bijv. Team, Familie", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}", "select_all": "Alles selecteren", "delete": "Verwijderen", + "delete_confirm_title": "Contacten verwijderen", "delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?", "deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}", "add_to_group": "Aan groep toevoegen", diff --git a/locales/pt/common.json b/locales/pt/common.json index 81fd5395..562eba64 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1230,6 +1230,7 @@ "empty_search_hint": "Tente outro termo de pesquisa", "clear_search": "Limpar pesquisa", "import_vcard": "Importar vCard", + "delete_confirm_title": "Excluir contato", "delete_confirm": "Tem certeza de que deseja excluir este contato?", "local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)", "back_to_mail": "Voltar ao e-mail", @@ -1376,6 +1377,7 @@ "create": "Novo grupo", "edit": "Editar grupo", "empty": "Nenhum grupo", + "delete_confirm_title": "Excluir grupo", "delete_confirm": "Tem certeza de que deseja excluir este grupo?", "name_label": "Nome do grupo", "name_placeholder": "ex. Equipe, Família", @@ -1412,6 +1414,7 @@ "selected": "{count, plural, one {1 selecionado} other {# selecionados}}", "select_all": "Selecionar tudo", "delete": "Excluir", + "delete_confirm_title": "Excluir contatos", "delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?", "deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}", "add_to_group": "Adicionar ao grupo", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index d35a3049..1ca8e622 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -247,13 +247,27 @@ export const useContactStore = create()( const { contacts } = get(); const group = contacts.find(c => c.id === groupId); if (!group?.members) return []; - const memberIds = Object.keys(group.members).filter(k => group.members![k]); - return contacts.filter(c => memberIds.includes(c.id) || memberIds.includes(c.uid || '')); + const memberKeys = Object.keys(group.members).filter(k => group.members![k]); + // Normalize: strip urn:uuid: prefix for matching + const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k); + return contacts.filter(c => { + if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true; + if (c.uid) { + const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; + return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid); + } + return false; + }); }, createGroup: async (client, name, memberIds) => { + const { contacts } = get(); const members: Record = {}; - memberIds.forEach(id => { members[id] = true; }); + memberIds.forEach(id => { + const contact = contacts.find(c => c.id === id); + const key = contact?.uid || id; + members[key] = true; + }); const groupData: Partial = { kind: 'group', @@ -294,7 +308,11 @@ export const useContactStore = create()( if (!group) return; const newMembers = { ...group.members }; - memberIds.forEach(id => { newMembers[id] = true; }); + memberIds.forEach(id => { + const contact = contacts.find(c => c.id === id); + const key = contact?.uid || id; + newMembers[key] = true; + }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { @@ -313,7 +331,28 @@ export const useContactStore = create()( if (!group?.members) return; const newMembers = { ...group.members }; - memberIds.forEach(id => { delete newMembers[id]; }); + memberIds.forEach(id => { + // Try direct id match first + if (newMembers[id] !== undefined) { + delete newMembers[id]; + return; + } + // Try uid-based match + const contact = contacts.find(c => c.id === id); + if (contact?.uid && newMembers[contact.uid] !== undefined) { + delete newMembers[contact.uid]; + } else { + // Try stripping urn:uuid: prefix matching + for (const key of Object.keys(newMembers)) { + const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key; + const bareUid = contact?.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact?.uid; + if (bareKey === id || bareKey === bareUid) { + delete newMembers[key]; + break; + } + } + } + }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) {