feat: include directory users in recipient autocomplete
This commit is contained in:
@@ -31,6 +31,8 @@ const defaultState = {
|
|||||||
supportsSync: false,
|
supportsSync: false,
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
activeTab: 'all' as const,
|
activeTab: 'all' as const,
|
||||||
|
directoryPrincipals: [],
|
||||||
|
directoryLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('contact-store', () => {
|
describe('contact-store', () => {
|
||||||
@@ -273,6 +275,28 @@ describe('contact-store', () => {
|
|||||||
const results = useContactStore.getState().getAutocomplete('Multi');
|
const results = useContactStore.getState().getAutocomplete('Multi');
|
||||||
expect(results).toHaveLength(2);
|
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', () => {
|
describe('getGroups', () => {
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ function initializeFeatureStores(client: IJMAPClient): void {
|
|||||||
useContactStore.getState().setSupportsSync(false);
|
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();
|
const vacationStore = useVacationStore.getState();
|
||||||
if (client.supportsVacationResponse()) {
|
if (client.supportsVacationResponse()) {
|
||||||
vacationStore.setSupported(true);
|
vacationStore.setSupported(true);
|
||||||
|
|||||||
@@ -159,7 +159,13 @@ interface ContactStore {
|
|||||||
lastSelectedContactId: string | null;
|
lastSelectedContactId: string | null;
|
||||||
activeTab: 'all' | 'groups';
|
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>;
|
fetchContacts: (client: IJMAPClient) => Promise<void>;
|
||||||
|
fetchDirectory: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||||
fetchAllAccountsAddressBooks: (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>(),
|
selectedContactIds: new Set<string>(),
|
||||||
lastSelectedContactId: null,
|
lastSelectedContactId: null,
|
||||||
activeTab: 'all' as const,
|
activeTab: 'all' as const,
|
||||||
|
directoryPrincipals: [],
|
||||||
|
directoryLoaded: false,
|
||||||
|
|
||||||
fetchContacts: async (client) => {
|
fetchContacts: async (client) => {
|
||||||
set({ isLoading: true, error: null });
|
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) => {
|
fetchAddressBooks: async (client) => {
|
||||||
try {
|
try {
|
||||||
const addressBooks = await client.getAllAddressBooks();
|
const addressBooks = await client.getAllAddressBooks();
|
||||||
@@ -474,6 +504,8 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
error: null,
|
error: null,
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
activeTab: 'all',
|
activeTab: 'all',
|
||||||
|
directoryPrincipals: [],
|
||||||
|
directoryLoaded: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getAutocomplete: (query) => {
|
getAutocomplete: (query) => {
|
||||||
@@ -516,6 +548,22 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
if (results.length >= 10) break;
|
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;
|
return results;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user