feat: include directory users in recipient autocomplete
This commit is contained in:
@@ -31,6 +31,8 @@ const defaultState = {
|
||||
supportsSync: false,
|
||||
selectedContactIds: new Set<string>(),
|
||||
activeTab: 'all' as const,
|
||||
directoryPrincipals: [],
|
||||
directoryLoaded: false,
|
||||
};
|
||||
|
||||
describe('contact-store', () => {
|
||||
@@ -273,6 +275,28 @@ describe('contact-store', () => {
|
||||
const results = useContactStore.getState().getAutocomplete('Multi');
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should augment results with directory principals', () => {
|
||||
useContactStore.setState({
|
||||
contacts: [],
|
||||
directoryPrincipals: [{ name: 'Dana Director', email: 'dana@example.com' }],
|
||||
});
|
||||
const results = useContactStore.getState().getAutocomplete('dana');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual({ name: 'Dana Director', email: 'dana@example.com' });
|
||||
});
|
||||
|
||||
it('should not duplicate a directory principal already matched as a contact', () => {
|
||||
useContactStore.setState({
|
||||
contacts: [
|
||||
makeContact({ id: 'c1', name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
|
||||
],
|
||||
directoryPrincipals: [{ name: 'Jane From Directory', email: 'JANE@example.com' }],
|
||||
});
|
||||
const results = useContactStore.getState().getAutocomplete('jane');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('Jane');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroups', () => {
|
||||
|
||||
@@ -239,6 +239,13 @@ function initializeFeatureStores(client: IJMAPClient): void {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
// Directory (RFC 9670 principals) is independent of contacts support and only
|
||||
// works when the server allows directory queries; populates recipient
|
||||
// autocomplete with other users on the server.
|
||||
if (client.supportsPrincipals()) {
|
||||
useContactStore.getState().fetchDirectory(client).catch((err) => debug.error('Failed to fetch directory:', err));
|
||||
}
|
||||
|
||||
const vacationStore = useVacationStore.getState();
|
||||
if (client.supportsVacationResponse()) {
|
||||
vacationStore.setSupported(true);
|
||||
|
||||
@@ -159,7 +159,13 @@ interface ContactStore {
|
||||
lastSelectedContactId: string | null;
|
||||
activeTab: 'all' | 'groups';
|
||||
|
||||
// Directory (RFC 9670 principals) — other users/groups on the server, used to
|
||||
// augment recipient autocomplete. Runtime only, not persisted.
|
||||
directoryPrincipals: Array<{ name: string; email: string; description?: string }>;
|
||||
directoryLoaded: boolean;
|
||||
|
||||
fetchContacts: (client: IJMAPClient) => Promise<void>;
|
||||
fetchDirectory: (client: IJMAPClient) => Promise<void>;
|
||||
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
||||
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||
@@ -258,6 +264,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
selectedContactIds: new Set<string>(),
|
||||
lastSelectedContactId: null,
|
||||
activeTab: 'all' as const,
|
||||
directoryPrincipals: [],
|
||||
directoryLoaded: false,
|
||||
|
||||
fetchContacts: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -270,6 +278,28 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
fetchDirectory: async (client) => {
|
||||
if (!client.supportsPrincipals()) return;
|
||||
try {
|
||||
const principals = await client.getPrincipals();
|
||||
const entries: Array<{ name: string; email: string; description?: string }> = [];
|
||||
for (const p of principals) {
|
||||
// Stalwart reports a principal's account name in `email`; only those
|
||||
// with an address are usable as recipients.
|
||||
const email = p.email?.trim();
|
||||
if (!email) continue;
|
||||
entries.push({
|
||||
name: p.name || email,
|
||||
email,
|
||||
description: p.description ?? undefined,
|
||||
});
|
||||
}
|
||||
set({ directoryPrincipals: entries, directoryLoaded: true });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch directory principals:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchAddressBooks: async (client) => {
|
||||
try {
|
||||
const addressBooks = await client.getAllAddressBooks();
|
||||
@@ -474,6 +504,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
error: null,
|
||||
selectedContactIds: new Set<string>(),
|
||||
activeTab: 'all',
|
||||
directoryPrincipals: [],
|
||||
directoryLoaded: false,
|
||||
}),
|
||||
|
||||
getAutocomplete: (query) => {
|
||||
@@ -516,6 +548,22 @@ export const useContactStore = create<ContactStore>()(
|
||||
if (results.length >= 10) break;
|
||||
}
|
||||
|
||||
// Augment with directory principals (other users on the server, RFC 9670).
|
||||
// Contacts take precedence, so skip any address already suggested.
|
||||
const { directoryPrincipals } = get();
|
||||
if (directoryPrincipals.length > 0) {
|
||||
const seen = new Set(results.map(r => r.email.toLowerCase()));
|
||||
for (const p of directoryPrincipals) {
|
||||
if (results.length >= 10) break;
|
||||
const addr = p.email.toLowerCase();
|
||||
if (seen.has(addr)) continue;
|
||||
if (p.name.toLowerCase().includes(lower) || addr.includes(lower)) {
|
||||
results.push({ name: p.name, email: p.email });
|
||||
seen.add(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user