fix: keep a contact created in a shared address book from being mistaken for an own contact with the same id

This commit is contained in:
Linus Rath
2026-10-26 12:25:31 +01:00
parent b038c82dcc
commit 4cfdc877d1
2 changed files with 73 additions and 9 deletions
+52
View File
@@ -1201,6 +1201,58 @@ describe('contact-store', () => {
);
});
// Card ids repeat across accounts (live on 0.16 and 1.0: the sharee's own
// card and one it creates in the owner's shared book both got "c"). A card
// created in a shared book was kept under its bare id: deleting it right
// after deleted the own card with that id.
it('keeps a card created in a shared book apart from an own card with the same id', async () => {
useContactStore.setState({
addressBooks: [
{ id: 'ab-1', name: 'Personal', isDefault: true },
{ id: 'acct-a:ab-7', originalId: 'ab-7', name: 'Team', isShared: true, accountId: 'acct-a', accountName: 'Alice' },
],
contacts: [makeContact({ id: 'c', addressBookIds: { 'ab-1': true } })],
});
const client = makeClient();
client.createContact.mockImplementation(async (c: Partial<ContactCard>) => ({ ...makeContact(), ...c, id: 'c' }));
await useContactStore.getState().createContact(
client as unknown as IJMAPClient,
{ addressBookIds: { 'acct-a:ab-7': true } },
);
expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: { 'ab-7': true } }), 'acct-a');
const created = useContactStore.getState().contacts[1];
expect(created).toMatchObject({
id: 'acct-a:c', originalId: 'c', isShared: true, accountId: 'acct-a', accountName: 'Alice',
addressBookIds: { 'acct-a:ab-7': true },
});
await useContactStore.getState().deleteContact(client as unknown as IJMAPClient, 'acct-a:c');
expect(client.deleteContact).toHaveBeenCalledWith('c', 'acct-a');
expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c']);
});
it('namespaces a card created through another login like that login\'s listing', async () => {
useContactStore.setState({
addressBooks: [
{ id: 'me@host::ab-1', originalId: 'ab-1', localAccountId: 'me@host', name: 'Personal', isDefault: true },
],
});
const client = makeClient();
await useContactStore.getState().createContact(
client as unknown as IJMAPClient,
{ addressBookIds: { 'me@host::ab-1': true } },
);
expect(useContactStore.getState().contacts[0]).toMatchObject({
id: 'me@host::created-id', originalId: 'created-id', localAccountId: 'me@host',
addressBookIds: { 'me@host::ab-1': true },
});
});
it('moves to a namespaced personal book with the raw id and keeps the display id locally', async () => {
const target = { id: 'me@host::ab-2', originalId: 'ab-2', localAccountId: 'me@host', name: 'Work' };
useContactStore.setState({
+21 -9
View File
@@ -710,19 +710,31 @@ export const useContactStore = create<ContactStore>()(
? await writeContactWithNewPhoto(owner, accountId, cleanedContact, options.photoKey,
(card) => owner.createContact(card, accountId))
: await owner.createContact(cleanedContact, accountId);
// Preserve shared account metadata
if (contact.isShared && contact.accountId) {
created.accountId = contact.accountId;
created.accountName = contact.accountName;
created.isShared = true;
created.id = `${contact.accountId}:${created.id}`;
created.originalId = created.id.includes(':') ? created.id.split(':').slice(1).join(':') : created.id;
// The card under the id the listings give it (getAllContacts, then
// prefixContactsWithLocalAccount): card ids repeat across accounts,
// and one from a shared book kept under its bare id was taken for
// the own card of that id by the next change or delete.
let stored = created;
if (accountId) {
stored = {
...created,
id: `${accountId}:${created.id}`,
originalId: created.id,
accountId,
accountName: contact.accountName
?? get().addressBooks.find(b => b.isShared && b.accountId === accountId)?.accountName,
isShared: true,
addressBookIds: Object.fromEntries(
Object.entries(created.addressBookIds ?? {}).map(([bookId, v]) => [`${accountId}:${bookId}`, v]),
),
};
}
if (localAccountId) stored = prefixContactsWithLocalAccount([stored], localAccountId)[0];
set((state) => ({
contacts: [...state.contacts, created],
contacts: [...state.contacts, stored],
isLoading: false,
}));
void contactHooks.onAfterContactCreate.emit(contactToView(created));
void contactHooks.onAfterContactCreate.emit(contactToView(stored));
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to create contact';