feat: enhance contact group management with uid normalization and improved member handling

This commit is contained in:
Linus Rath
2026-03-15 00:40:32 +01:00
parent 8c5e3ee240
commit 5accf0a86c
11 changed files with 153 additions and 32 deletions
+5 -6
View File
@@ -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;
+80 -21
View File
@@ -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<string, (args: MethodArgs, callId: string) => 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<string, unknown> = {};
const updated: Record<string, unknown> = {};
const destroyed: string[] = [];
if (args.create) {
for (const [tempId, data] of Object.entries(args.create as Record<string, Record<string, unknown>>)) {
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<string, Record<string, unknown>>)) {
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,
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "グループに追加",
+3
View File
@@ -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",
+3
View File
@@ -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",
+44 -5
View File
@@ -247,13 +247,27 @@ export const useContactStore = create<ContactStore>()(
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<string, boolean> = {};
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<ContactCard> = {
kind: 'group',
@@ -294,7 +308,11 @@ export const useContactStore = create<ContactStore>()(
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<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
@@ -313,7 +331,28 @@ export const useContactStore = create<ContactStore>()(
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<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {