feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907)

This commit is contained in:
Linus Rath
2026-08-29 17:50:44 +02:00
parent 580e010b33
commit 26a34d5900
5 changed files with 1308 additions and 275 deletions
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import { queryRecentRecipients, searchSentRecipients } from '../recent-recipients';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('queryRecentRecipients', () => {
it('scans Sent To/Cc newest-first and keeps the first name per address', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/query', { ids: ['m1', 'm2'] }, '0'],
['Email/get', {
list: [
{ id: 'm1', to: [{ name: 'Jane Doe', email: 'jane@x.com' }], cc: [{ email: 'cc@x.com' }] },
{ id: 'm2', to: [{ name: 'Old Jane', email: 'JANE@x.com' }, { name: 'Bob', email: 'bob@x.com' }] },
],
}, '1'],
],
});
const result = await queryRecentRecipients('sent-1');
expect(result).toEqual([
{ name: 'Jane Doe', email: 'jane@x.com' },
{ name: '', email: 'cc@x.com' },
{ name: 'Bob', email: 'bob@x.com' },
]);
const [query, get] = mockRequest.mock.calls[0][0];
expect(query[1]).toMatchObject({
accountId: 'acc-1',
filter: { inMailbox: 'sent-1' },
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 300,
});
expect(get[1].properties).toEqual(['to', 'cc']);
expect(get[1]['#ids']).toEqual({ resultOf: '0', name: 'Email/query', path: '/ids' });
});
it('returns nothing without a Sent mailbox', async () => {
expect(await queryRecentRecipients('')).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('searchSentRecipients', () => {
it('only keeps the recipients that match the query', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/query', { ids: ['m1'] }, '0'],
['Email/get', {
list: [{ id: 'm1', to: [{ name: 'Jane', email: 'jane@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }],
}, '1'],
],
});
expect(await searchSentRecipients('jan', 'sent-1')).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
const query = mockRequest.mock.calls[0][0][0];
expect(query[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'sent-1' }, { operator: 'OR', conditions: [{ to: 'jan' }, { cc: 'jan' }] }],
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { EmailAddress } from './types';
export interface RecentRecipient {
name: string;
email: string;
}
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
function collect(
emails: Array<{ to?: EmailAddress[] | null; cc?: EmailAddress[] | null }>,
keep: (key: string, name: string) => boolean = () => true,
): RecentRecipient[] {
// Messages arrive newest-first, so the first occurrence per address is the
// most recent one and carries the freshest display name.
const byEmail = new Map<string, RecentRecipient>();
for (const email of emails) {
for (const r of [...(email.to || []), ...(email.cc || [])]) {
if (!r?.email) continue;
const key = r.email.toLowerCase().trim();
if (!key || byEmail.has(key)) continue;
const name = (r.name || '').trim();
if (!keep(key, name)) continue;
byEmail.set(key, { name, email: r.email });
}
}
return Array.from(byEmail.values());
}
/**
* The people the user has written to: scans the newest `limit` messages of the
* Sent mailbox and returns each To/Cc address once (OWA-style autocomplete
* cache). Only the recipient headers are fetched - no subject, preview or body.
*/
export async function queryRecentRecipients(
sentMailboxId: string,
limit = 300,
accountId?: string,
): Promise<RecentRecipient[]> {
if (!sentMailboxId) return [];
const account = accountId || jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId: account,
filter: { inMailbox: sentMailboxId },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
['Email/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['to', 'cc'],
}, '1'],
], USING);
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
to?: EmailAddress[] | null;
cc?: EmailAddress[] | null;
}>;
return collect(list);
}
/**
* On-demand server search of the Sent mailbox for recipients matching `query`
* (used when the local caches have no hit). Keeps only the addresses that
* actually match, not every co-recipient of a matching message.
*/
export async function searchSentRecipients(
query: string,
sentMailboxId: string,
limit = 60,
accountId?: string,
): Promise<RecentRecipient[]> {
const q = query.trim();
if (!q || !sentMailboxId) return [];
const account = accountId || jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId: account,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: sentMailboxId },
{ operator: 'OR', conditions: [{ to: q }, { cc: q }] },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
['Email/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['to', 'cc'],
}, '1'],
], USING);
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
to?: EmailAddress[] | null;
cc?: EmailAddress[] | null;
}>;
const lower = q.toLowerCase();
return collect(list, (key, name) => key.includes(lower) || name.toLowerCase().includes(lower));
}
+10 -5
View File
@@ -51,7 +51,7 @@ export default function ContactImportSheet({
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map()); const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [importing, setImporting] = React.useState(false); const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null); const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current; const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -144,9 +144,9 @@ export default function ContactImportSheet({
if (toImport.length === 0) return; if (toImport.length === 0) return;
setImporting(true); setImporting(true);
try { try {
const count = await importContacts(toImport, targetBookId); const outcome = await importContacts(toImport, targetBookId);
setResult(count); setResult(outcome);
onImported?.(count); onImported?.(outcome.imported);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.'); setError(e instanceof Error ? e.message : 'Import failed.');
} finally { } finally {
@@ -174,8 +174,13 @@ export default function ContactImportSheet({
<Check size={26} color={c.primaryForeground} /> <Check size={26} color={c.primaryForeground} />
</View> </View>
<Text style={styles.successText}> <Text style={styles.successText}>
Imported {result} contact{result === 1 ? '' : 's'}. Imported {result.imported} contact{result.imported === 1 ? '' : 's'}.
</Text> </Text>
{result.failed > 0 && (
<Text style={[styles.successText, { color: c.error }]}>
{result.failed} contact{result.failed === 1 ? '' : 's'} could not be imported.
</Text>
)}
<Button variant="outline" size="sm" onPress={onClose}>Done</Button> <Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View> </View>
) : parsed.length === 0 ? ( ) : parsed.length === 0 ? (
+392 -15
View File
@@ -2,11 +2,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/contacts', () => ({ vi.mock('../../api/contacts', () => ({
getAddressBooks: vi.fn(), getAddressBooks: vi.fn(),
getAllAddressBooks: vi.fn(),
queryContacts: vi.fn(), queryContacts: vi.fn(),
getContacts: vi.fn(), getContacts: vi.fn(),
getAllContacts: vi.fn(),
createContact: vi.fn(), createContact: vi.fn(),
updateContact: vi.fn(), updateContact: vi.fn(),
deleteContacts: vi.fn(), deleteContacts: vi.fn(),
createAddressBook: vi.fn(),
updateAddressBook: vi.fn(),
deleteAddressBook: vi.fn(),
setDefaultAddressBook: vi.fn(),
getContactsInBook: vi.fn(),
getContactsAccountId: () => 'acc-1',
getContactCapableAccountIds: () => ['acc-1', 'acc-team'],
namespaceId: (accountId: string, id: string) => `${accountId}:${id}`,
stripClientFields: (c: Record<string, unknown>) => {
const { originalId: _o, accountId: _a, accountName: _n, isShared: _s, ...rest } = c;
return rest;
},
}));
vi.mock('../../api/recent-recipients', () => ({
queryRecentRecipients: vi.fn(),
searchSentRecipients: vi.fn(),
})); }));
vi.mock('@react-native-async-storage/async-storage', () => ({ vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -25,14 +44,32 @@ vi.mock('../../api/jmap-client', () => ({
})); }));
import * as contactsApi from '../../api/contacts'; import * as contactsApi from '../../api/contacts';
import { useContactsStore } from '../contacts-store'; import * as recentApi from '../../api/recent-recipients';
import {
useContactsStore,
cleanGroupMembers,
normalizeSuggestions,
selectVisibleContacts,
selectGroupMembers,
} from '../contacts-store';
import type { ContactCard } from '../../api/types';
const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType<typeof vi.fn>; const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType<typeof vi.fn>;
const mockGetAllAddressBooks = contactsApi.getAllAddressBooks as ReturnType<typeof vi.fn>;
const mockQueryContacts = contactsApi.queryContacts as ReturnType<typeof vi.fn>; const mockQueryContacts = contactsApi.queryContacts as ReturnType<typeof vi.fn>;
const mockGetContacts = contactsApi.getContacts as ReturnType<typeof vi.fn>; const mockGetContacts = contactsApi.getContacts as ReturnType<typeof vi.fn>;
const mockGetAllContacts = contactsApi.getAllContacts as ReturnType<typeof vi.fn>;
const mockCreateContact = contactsApi.createContact as ReturnType<typeof vi.fn>; const mockCreateContact = contactsApi.createContact as ReturnType<typeof vi.fn>;
const mockUpdateContact = contactsApi.updateContact as ReturnType<typeof vi.fn>; const mockUpdateContact = contactsApi.updateContact as ReturnType<typeof vi.fn>;
const mockDeleteContacts = contactsApi.deleteContacts as ReturnType<typeof vi.fn>; const mockDeleteContacts = contactsApi.deleteContacts as ReturnType<typeof vi.fn>;
const mockCreateAddressBook = contactsApi.createAddressBook as ReturnType<typeof vi.fn>;
const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType<typeof vi.fn>;
const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType<typeof vi.fn>;
const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType<typeof vi.fn>;
const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType<typeof vi.fn>;
const card = (id: string, extra: Partial<ContactCard> = {}): ContactCard =>
({ id, addressBookIds: { 'ab-1': true }, ...extra }) as ContactCard;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -41,18 +78,29 @@ beforeEach(() => {
describe('contacts-store', () => { describe('contacts-store', () => {
describe('fetchAddressBooks', () => { describe('fetchAddressBooks', () => {
it('should load address books', async () => { it('should load address books from every contacts-capable account', async () => {
const books = [{ id: 'ab-1', name: 'Personal' }]; const books = [{ id: 'ab-1', name: 'Personal' }, { id: 'acc-team:ab', name: 'Team', isShared: true }];
mockGetAddressBooks.mockResolvedValue(books); mockGetAllAddressBooks.mockResolvedValue(books);
await useContactsStore.getState().fetchAddressBooks(); await useContactsStore.getState().fetchAddressBooks();
expect(useContactsStore.getState().addressBooks).toEqual(books); expect(useContactsStore.getState().addressBooks).toEqual(books);
}); });
it('shares one in-flight request between concurrent callers', async () => {
let resolve: (v: unknown) => void = () => {};
mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolve = r; }));
const a = useContactsStore.getState().fetchAddressBooks();
const b = useContactsStore.getState().fetchAddressBooks();
resolve([{ id: 'ab-1', name: 'P' }]);
await Promise.all([a, b]);
expect(mockGetAllAddressBooks).toHaveBeenCalledTimes(1);
expect(useContactsStore.getState().addressBooks).toHaveLength(1);
});
}); });
describe('fetchContacts', () => { describe('fetchContacts', () => {
it('should query and fetch contacts', async () => { it('should query and fetch contacts with a filter', async () => {
mockQueryContacts.mockResolvedValue(['c1', 'c2']); mockQueryContacts.mockResolvedValue(['c1', 'c2']);
const contacts = [{ id: 'c1' }, { id: 'c2' }]; const contacts = [{ id: 'c1' }, { id: 'c2' }];
mockGetContacts.mockResolvedValue(contacts); mockGetContacts.mockResolvedValue(contacts);
@@ -66,50 +114,379 @@ describe('contacts-store', () => {
it('should handle empty results', async () => { it('should handle empty results', async () => {
mockQueryContacts.mockResolvedValue([]); mockQueryContacts.mockResolvedValue([]);
await useContactsStore.getState().fetchContacts(); await useContactsStore.getState().fetchContacts({ text: 'nobody' });
expect(useContactsStore.getState().contacts).toEqual([]); expect(useContactsStore.getState().contacts).toEqual([]);
expect(mockGetContacts).not.toHaveBeenCalled(); expect(mockGetContacts).not.toHaveBeenCalled();
}); });
it('loads every account without a filter', async () => {
mockGetAllContacts.mockResolvedValue([{ id: 'c1' }, { id: 'acc-team:c2', isShared: true }]);
await useContactsStore.getState().fetchContacts();
expect(useContactsStore.getState().contacts).toHaveLength(2);
});
it('waits for an in-flight address book load before querying', async () => {
const order: string[] = [];
let resolveBooks: (v: unknown) => void = () => {};
mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; }));
mockGetAllContacts.mockImplementation(async () => { order.push('contacts'); return []; });
const books = useContactsStore.getState().fetchAddressBooks();
const contacts = useContactsStore.getState().fetchContacts();
expect(order).toEqual([]);
order.push('books-resolved');
resolveBooks([]);
await Promise.all([books, contacts]);
expect(order).toEqual(['books-resolved', 'contacts']);
});
}); });
describe('createContact', () => { describe('createContact', () => {
it('should create and append to list', async () => { it('should create and append to list', async () => {
useContactsStore.setState({ contacts: [{ id: 'c1' } as any] }); useContactsStore.setState({ contacts: [card('c1')], addressBooks: [{ id: 'ab-1', name: 'P' }] });
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } }; const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
mockCreateContact.mockResolvedValue(created); mockCreateContact.mockResolvedValue(created);
const result = await useContactsStore.getState().createContact({}, 'ab-1'); const result = await useContactsStore.getState().createContact({}, 'ab-1');
expect(result).toEqual(created); expect(result).toEqual(created);
expect(mockCreateContact).toHaveBeenCalledWith({}, 'ab-1', undefined);
expect(useContactsStore.getState().contacts).toHaveLength(2); expect(useContactsStore.getState().contacts).toHaveLength(2);
}); });
it('routes a shared book to its account and namespaces the result', async () => {
useContactsStore.setState({
addressBooks: [
{ id: 'ab-1', name: 'P' },
{ id: 'acc-team:ab', originalId: 'ab', name: 'Team', accountId: 'acc-team', accountName: 'Team', isShared: true },
],
});
mockCreateContact.mockResolvedValue({ id: 'c9', addressBookIds: { ab: true } });
const result = await useContactsStore.getState().createContact({ name: { full: 'X' } }, 'acc-team:ab');
expect(mockCreateContact).toHaveBeenCalledWith({ name: { full: 'X' } }, 'ab', 'acc-team');
expect(result).toMatchObject({
id: 'acc-team:c9', originalId: 'c9', accountId: 'acc-team', isShared: true, addressBookIds: { 'acc-team:ab': true },
});
});
}); });
describe('updateContact', () => { describe('updateContact', () => {
it('should update and merge changes in state', async () => { it('should update and merge changes in state', async () => {
useContactsStore.setState({ useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] });
contacts: [{ id: 'c1', kind: 'individual' } as any],
});
mockUpdateContact.mockResolvedValue(undefined); mockUpdateContact.mockResolvedValue(undefined);
await useContactsStore.getState().updateContact('c1', { kind: 'org' } as any); await useContactsStore.getState().updateContact('c1', { kind: 'org' });
expect(useContactsStore.getState().contacts[0].kind).toBe('org'); expect(useContactsStore.getState().contacts[0].kind).toBe('org');
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { kind: 'org' }, undefined);
});
it('drops properties patched to null locally', async () => {
useContactsStore.setState({ contacts: [card('c1', { phones: { p1: { number: '1' } } })] });
mockUpdateContact.mockResolvedValue(undefined);
await useContactsStore.getState().updateContact('c1', { phones: null } as never);
expect(useContactsStore.getState().contacts[0].phones).toBeUndefined();
});
it('does not apply changes the server refused', async () => {
useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] });
mockUpdateContact.mockRejectedValue(new Error('read-only'));
await expect(useContactsStore.getState().updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only');
expect(useContactsStore.getState().contacts[0].kind).toBe('individual');
});
it('de-namespaces book ids and routes shared cards to their account', async () => {
useContactsStore.setState({
contacts: [card('acc-team:c1', { originalId: 'c1', accountId: 'acc-team', isShared: true })],
});
mockUpdateContact.mockResolvedValue(undefined);
await useContactsStore.getState().updateContact('acc-team:c1', { addressBookIds: { 'acc-team:ab': true } });
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { addressBookIds: { ab: true } }, 'acc-team');
}); });
}); });
describe('deleteContact', () => { describe('deleteContact', () => {
it('should remove from list', async () => { it('should remove from list and clean group members', async () => {
useContactsStore.setState({ useContactsStore.setState({
contacts: [{ id: 'c1' } as any, { id: 'c2' } as any], contacts: [
card('c1', { uid: 'urn:uuid:u1' }),
card('c2'),
card('g1', { kind: 'group', members: { u1: true, c2: true } }),
],
}); });
mockDeleteContacts.mockResolvedValue(undefined); mockDeleteContacts.mockResolvedValue(['c1']);
await useContactsStore.getState().deleteContact('c1'); await useContactsStore.getState().deleteContact('c1');
const contacts = useContactsStore.getState().contacts;
expect(contacts.map((c) => c.id)).toEqual(['c2', 'g1']);
expect(contacts[1].members).toEqual({ c2: true });
});
});
describe('bulkDelete', () => {
it('only drops the ids the server destroyed', async () => {
useContactsStore.setState({ contacts: [card('c1'), card('c2'), card('c3')] });
mockDeleteContacts.mockResolvedValue(['c1', 'c3']);
await useContactsStore.getState().bulkDelete(['c1', 'c2', 'c3']);
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c2']);
expect(useContactsStore.getState().error).toMatch(/1 contact/);
});
it('groups cards by account', async () => {
useContactsStore.setState({
contacts: [card('c1'), card('acc-team:c2', { originalId: 'c2', accountId: 'acc-team', isShared: true })],
});
mockDeleteContacts.mockImplementation(async (ids: string[]) => ids);
await useContactsStore.getState().bulkDelete(['c1', 'acc-team:c2']);
expect(mockDeleteContacts).toHaveBeenCalledWith(['c1'], undefined);
expect(mockDeleteContacts).toHaveBeenCalledWith(['c2'], 'acc-team');
expect(useContactsStore.getState().contacts).toEqual([]);
});
});
describe('importContacts', () => {
it('reports imported and failed counts', async () => {
useContactsStore.setState({ addressBooks: [{ id: 'ab-1', name: 'P' }] });
mockCreateContact
.mockResolvedValueOnce({ id: 'n1', addressBookIds: { 'ab-1': true } })
.mockRejectedValueOnce(new Error('invalid'));
const result = await useContactsStore.getState().importContacts(
[{ id: 'import-1', name: { full: 'A' } }, { id: 'import-2', name: { full: 'B' } }],
'ab-1',
);
expect(result).toEqual({ imported: 1, failed: 1 });
expect(useContactsStore.getState().contacts).toHaveLength(1); expect(useContactsStore.getState().contacts).toHaveLength(1);
expect(useContactsStore.getState().contacts[0].id).toBe('c2'); });
});
describe('address books', () => {
it('deleting a book drops its cards from the cache', async () => {
useContactsStore.setState({
addressBooks: [{ id: 'ab-1', name: 'P' }, { id: 'ab-2', name: 'Q' }],
contacts: [card('c1'), card('c2', { addressBookIds: { 'ab-2': true } })],
});
mockDeleteAddressBook.mockResolvedValue(undefined);
await useContactsStore.getState().deleteAddressBook('ab-2');
expect(useContactsStore.getState().addressBooks.map((b) => b.id)).toEqual(['ab-1']);
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c1']);
});
it('setDefaultAddressBook flips the flag on siblings', async () => {
useContactsStore.setState({
addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }, { id: 'ab-2', name: 'Q' }],
});
mockSetDefaultAddressBook.mockResolvedValue(undefined);
await useContactsStore.getState().setDefaultAddressBook('ab-2');
expect(mockSetDefaultAddressBook).toHaveBeenCalledWith('ab-2', undefined);
expect(useContactsStore.getState().addressBooks.map((b) => !!b.isDefault)).toEqual([false, true]);
});
it('getDefaultAddressBookId prefers the default own book', () => {
useContactsStore.setState({
addressBooks: [
{ id: 'acc-team:ab', name: 'Team', isShared: true, isDefault: true },
{ id: 'ab-1', name: 'P' },
{ id: 'ab-2', name: 'Q', isDefault: true },
],
});
expect(useContactsStore.getState().getDefaultAddressBookId()).toBe('ab-2');
});
});
describe('groups', () => {
it('addContactsToGroup references members by uid', async () => {
useContactsStore.setState({
contacts: [
card('g1', { kind: 'group', members: { existing: true } }),
card('c1', { uid: 'urn:uuid:u1' }),
card('c2'),
],
});
mockUpdateContact.mockResolvedValue(undefined);
await useContactsStore.getState().addContactsToGroup('g1', ['c1', 'c2']);
expect(mockUpdateContact).toHaveBeenCalledWith(
'g1', { members: { existing: true, 'urn:uuid:u1': true, c2: true } }, undefined,
);
});
it('createGroup creates a kind=group card in the default book', async () => {
useContactsStore.setState({
addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }],
contacts: [card('c1', { uid: 'u1' })],
});
mockCreateContact.mockResolvedValue({ id: 'g1', kind: 'group', addressBookIds: { 'ab-1': true } });
await useContactsStore.getState().createGroup('Sales', ['c1']);
expect(mockCreateContact).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'group', members: { u1: true }, name: expect.objectContaining({ full: 'Sales' }) }),
'ab-1',
undefined,
);
});
it('getGroupRecipients dedupes addresses case-insensitively', () => {
useContactsStore.setState({
contacts: [
card('g1', { kind: 'group', members: { c1: true, c2: true, c3: true } }),
card('c1', { name: { full: 'A' }, emails: { e: { address: 'a@x.com' } } }),
card('c2', { name: { full: 'A2' }, emails: { e: { address: 'A@X.COM' } } }),
card('c3', { name: { full: 'B <b@x.com>' }, emails: { e: { address: 'b@x.com' } } }),
],
});
expect(useContactsStore.getState().getGroupRecipients('g1')).toEqual([
{ name: 'A', email: 'a@x.com' },
{ name: 'B', email: 'b@x.com' },
]);
});
});
describe('renameKeyword', () => {
it('rewrites the keyword on every affected card', async () => {
useContactsStore.setState({
contacts: [card('c1', { keywords: { Old: true, Keep: true } }), card('c2', { keywords: { Other: true } })],
selectedCategory: { type: 'keyword', keyword: 'Old' },
});
mockUpdateContact.mockResolvedValue(undefined);
await useContactsStore.getState().renameKeyword('Old', 'New');
expect(mockUpdateContact).toHaveBeenCalledTimes(1);
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, New: true } }, undefined);
expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'keyword', keyword: 'New' });
});
});
describe('autocomplete', () => {
it('merges contacts, groups and recent recipients without duplicates', async () => {
useContactsStore.setState({
contacts: [
card('c1', { name: { full: 'Jane Doe' }, emails: { e: { address: 'jane@x.com' } } }),
card('g1', { kind: 'group', name: { full: 'Jane fans' }, members: { c1: true } }),
],
});
mockQueryRecent.mockResolvedValue([
{ name: 'Jane Doe', email: 'JANE@x.com' },
{ name: 'Janet', email: 'janet@x.com' },
]);
await useContactsStore.getState().loadRecentRecipients('sent-1');
expect(mockQueryRecent).toHaveBeenCalledWith('sent-1', 300);
const results = useContactsStore.getState().getAutocomplete('jan');
expect(results).toEqual([
{ name: 'Jane Doe', email: 'jane@x.com' },
{ name: 'Jane fans', email: '', group: { id: 'g1', memberCount: 1 } },
{ name: 'Janet', email: 'janet@x.com' },
]);
});
it('loadRecentRecipients is a no-op once loaded', async () => {
mockQueryRecent.mockResolvedValue([]);
await useContactsStore.getState().loadRecentRecipients('sent-1');
await useContactsStore.getState().loadRecentRecipients('sent-1');
expect(mockQueryRecent).toHaveBeenCalledTimes(1);
});
it('findContactByEmail matches case-insensitively and accepts a mailbox', () => {
useContactsStore.setState({
contacts: [card('c1', { emails: { e: { address: 'Jane@X.com' } } })],
});
expect(useContactsStore.getState().findContactByEmail('jane@x.com')?.id).toBe('c1');
expect(useContactsStore.getState().findContactByEmail('Jane <JANE@x.com>')?.id).toBe('c1');
expect(useContactsStore.getState().findContactByEmail('nobody@x.com')).toBeUndefined();
});
});
describe('trusted senders', () => {
it('add-after-load waits for the in-flight passive load', async () => {
let resolveBooks: (v: unknown) => void = () => {};
mockGetAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; }));
mockGetContactsInBook.mockResolvedValue([]);
mockCreateContact.mockResolvedValue({ id: 't1' });
const passive = useContactsStore.getState().loadTrustedSendersBook();
const add = useContactsStore.getState().addToTrustedSendersBook('Jane <Jane@X.com>');
resolveBooks([{ id: 'ab-ts-b', name: 'Trusted Senders' }, { id: 'ab-ts-a', name: 'Trusted Senders' }]);
await Promise.all([passive, add]);
expect(mockGetAddressBooks).toHaveBeenCalledTimes(1);
// Duplicate books: the lowest id wins on every client (#730).
expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-ts-a');
expect(mockCreateContact).toHaveBeenCalledWith(
{ name: { full: 'Jane' }, emails: { email: { address: 'jane@x.com' } } },
'ab-ts-a',
);
expect(useContactsStore.getState().trustedSenderEmails).toEqual(['jane@x.com']);
});
it('creates the book on first add when the passive load found none', async () => {
mockGetAddressBooks.mockResolvedValue([]);
mockCreateAddressBook.mockResolvedValue({ id: 'ab-new', name: 'Trusted Senders' });
mockGetContactsInBook.mockResolvedValue([]);
mockCreateContact.mockResolvedValue({ id: 't1' });
await useContactsStore.getState().loadTrustedSendersBook();
expect(useContactsStore.getState().trustedSendersBookId).toBeNull();
expect(mockCreateAddressBook).not.toHaveBeenCalled();
await useContactsStore.getState().addToTrustedSendersBook('x@y.com');
expect(mockCreateAddressBook).toHaveBeenCalledWith('Trusted Senders');
expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-new');
});
it('removeFromTrustedSendersBook deletes the matching card', async () => {
useContactsStore.setState({ trustedSendersBookId: 'ab-ts', trustedSenderEmails: ['x@y.com', 'z@y.com'] });
mockGetContactsInBook.mockResolvedValue([
card('t1', { emails: { e: { address: 'X@y.com' } } }),
card('t2', { emails: { e: { address: 'z@y.com' } } }),
]);
mockDeleteContacts.mockResolvedValue(['t1']);
await useContactsStore.getState().removeFromTrustedSendersBook('Someone <x@y.com>');
expect(mockDeleteContacts).toHaveBeenCalledWith(['t1']);
expect(useContactsStore.getState().trustedSenderEmails).toEqual(['z@y.com']);
}); });
}); });
}); });
describe('helpers', () => {
it('cleanGroupMembers strips id, uid and urn:uuid variants', () => {
const contacts = [
card('c1', { uid: 'urn:uuid:u1' }),
card('g1', { kind: 'group', members: { 'urn:uuid:u1': true, u1: true, c1: true, other: true } }),
];
const cleaned = cleanGroupMembers(contacts, new Set(['c1']));
expect(cleaned[1].members).toEqual({ other: true });
});
it('normalizeSuggestions collapses mailbox-shaped names (#672)', () => {
expect(normalizeSuggestions([
{ name: 'Jane <j@x.com>', email: 'j@x.com' },
{ name: '', email: 'J@x.com' },
{ name: '', email: 'Ann <a@x.com>' },
{ name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } },
])).toEqual([
{ name: 'Jane', email: 'j@x.com' },
{ name: 'Ann', email: 'a@x.com' },
{ name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } },
]);
});
it('uncategorized means "no keywords"', () => {
const state = {
...useContactsStore.getState(),
contacts: [card('c1', { keywords: { A: true } }), card('c2'), card('g', { kind: 'group' })],
selectedCategory: { type: 'uncategorized' as const },
};
expect(selectVisibleContacts(state, '').map((c) => c.id)).toEqual(['c2']);
});
it('selectGroupMembers resolves shared cards by originalId', () => {
const contacts = [
card('g1', { kind: 'group', members: { c9: true } }),
card('acc-team:c9', { originalId: 'c9', isShared: true }),
];
expect(selectGroupMembers({ contacts }, 'g1').map((c) => c.id)).toEqual(['acc-team:c9']);
});
});
File diff suppressed because it is too large Load Diff