fix: suggest recipients whose name starts with what you type first

This commit is contained in:
Linus Rath
2026-11-04 06:42:47 +01:00
parent 4ec1c128f6
commit 317f3b40dd
2 changed files with 85 additions and 18 deletions
+41
View File
@@ -419,6 +419,47 @@ describe('contact-store', () => {
expect(results).toEqual([{ name: 'Jane Doe', email: 'jane.doe@example.com' }]);
});
// "an" offered Daniel Okafor and Greta Hoffmann before Anna Becker.
it('should put names and addresses that start with the query first', () => {
const person = (id: string, given: string, surname: string, address: string) => makeContact({
id,
name: { components: [{ kind: 'given', value: given }, { kind: 'surname', value: surname }], isOrdered: true },
emails: { e0: { address } },
});
useContactStore.setState({
contacts: [
person('d', 'Daniel', 'Okafor', 'daniel@example.com'),
person('g', 'Greta', 'Hoffmann', 'greta@example.com'),
person('a', 'Anna', 'Becker', 'anna@example.com'),
person('h', 'Hiro', 'Tanaka', 'hiro@example.com'),
person('m', 'Mia', 'Schulz', 'mia.anders@example.com'),
],
directoryPrincipals: [],
recentRecipients: [{ name: '', email: 'brandt@example.com' }, { name: 'Andreas Wolf', email: 'wolf@example.com' }],
});
const results = useContactStore.getState().getAutocomplete('an');
expect(results.map((r) => r.email)).toEqual([
'anna@example.com',
'mia.anders@example.com',
'wolf@example.com',
'daniel@example.com',
'greta@example.com',
'hiro@example.com',
'brandt@example.com',
]);
});
it('should not lose a name that starts with the query behind ten that only contain it', () => {
const contacts = Array.from({ length: 12 }, (_, i) =>
makeContact({ id: `d${i}`, name: { components: [{ kind: 'given', value: `Dan${i}` }], isOrdered: true }, emails: { e0: { address: `dan${i}@example.com` } } }),
);
contacts.push(makeContact({ id: 'anna', name: { components: [{ kind: 'given', value: 'Anna' }], isOrdered: true }, emails: { e0: { address: 'anna@example.com' } } }));
useContactStore.setState({ contacts, directoryPrincipals: [], recentRecipients: [] });
const results = useContactStore.getState().getAutocomplete('an');
expect(results).toHaveLength(10);
expect(results[0].email).toBe('anna@example.com');
});
it('should repair a recent recipient whose address lost its closing bracket', () => {
useContactStore.setState({
contacts: [],
+44 -18
View File
@@ -41,6 +41,27 @@ function sameIdSet(a: Set<string>, b: Set<string>): boolean {
/** One compose-autocomplete suggestion: a person, or a contact group. */
type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } };
/** How many suggestions the compose autocomplete offers. */
const MAX_SUGGESTIONS = 10;
/**
* How well `query` (lowercase) matches any of `texts`: 0 at the start of a
* word ("an" in "Anna" or "an@x"), 1 inside a word ("an" in "Daniel"),
* -1 not at all. Word starts come first in the suggestions.
*/
function matchRank(query: string, ...texts: (string | undefined)[]): number {
let rank = -1;
for (const text of texts) {
if (!text) continue;
const lower = text.toLowerCase();
for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, at + 1)) {
if (at === 0 || !/[\p{L}\p{N}]/u.test(lower[at - 1])) return 0;
rank = 1;
}
}
return rank;
}
/**
* Reduces every suggestion to a clean display name plus a bare address and
* collapses the ones that resolve to the same address.
@@ -925,17 +946,26 @@ export const useContactStore = create<ContactStore>()(
const lower = query.toLowerCase();
const results: RecipientSuggestion[] = [];
// Every match is collected and ranked: a word that starts with the
// query ("an": Anna, an@x) before one that only contains it
// (Daniel), each kind in the order contacts, directory, recent.
const ranks = new Map<RecipientSuggestion, number>();
const add = (suggestion: RecipientSuggestion, rank: number) => {
results.push(suggestion);
ranks.set(suggestion, rank);
};
for (const contact of contacts) {
if (contact.kind === 'group') {
// Suggest the group itself as a single entry (Outlook-style);
// the composer turns it into one chip carrying the members.
const groupName = getContactDisplayName(contact);
if (groupName && groupName.toLowerCase().includes(lower)) {
const rank = matchRank(lower, groupName);
if (rank >= 0) {
const memberCount = get().getGroupMembers(contact.id)
.filter(m => getContactPrimaryEmail(m).trim()).length;
if (memberCount > 0) {
results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } });
add({ name: groupName, email: '', group: { id: contact.id, memberCount } }, rank);
}
}
continue;
@@ -946,15 +976,9 @@ export const useContactStore = create<ContactStore>()(
for (const emailEntry of emails) {
if (!emailEntry.address) continue;
if (
name.toLowerCase().includes(lower) ||
emailEntry.address.toLowerCase().includes(lower)
) {
results.push({ name, email: emailEntry.address });
}
const rank = matchRank(lower, name, emailEntry.address);
if (rank >= 0) add({ name, email: emailEntry.address }, rank);
}
if (results.length >= 10) break;
}
// Augment with directory principals (other users on the server, RFC 9670).
@@ -963,7 +987,6 @@ export const useContactStore = create<ContactStore>()(
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)) {
const betterName = p.description || p.name;
@@ -975,10 +998,11 @@ export const useContactStore = create<ContactStore>()(
}
continue;
}
if (p.name.toLowerCase().includes(lower) || addr.includes(lower) || (p.description && p.description.toLowerCase().includes(lower))) {
const rank = matchRank(lower, p.name, addr, p.description);
if (rank >= 0) {
const displayName = p.description || p.name;
const name = displayName !== p.email ? displayName : '';
results.push({ name, email: p.email });
add({ name, email: p.email }, rank);
seen.add(addr);
}
}
@@ -990,20 +1014,22 @@ export const useContactStore = create<ContactStore>()(
// duplicates). These carry the display name from the message, so match
// on name or address.
const { recentRecipients } = get();
if (recentRecipients.length > 0 && results.length < 10) {
if (recentRecipients.length > 0) {
const seenRecent = new Set(results.map(r => r.email.toLowerCase()));
for (const rec of recentRecipients) {
if (results.length >= 10) break;
const addr = rec.email.toLowerCase();
if (seenRecent.has(addr)) continue;
if (addr.includes(lower) || (rec.name && rec.name.toLowerCase().includes(lower))) {
results.push({ name: rec.name, email: rec.email });
const rank = matchRank(lower, addr, rec.name);
if (rank >= 0) {
add({ name: rec.name, email: rec.email }, rank);
seenRecent.add(addr);
}
}
}
return normalizeSuggestions(results);
// Array.prototype.sort is stable, so each rank keeps the order above.
const ranked = [...results].sort((a, b) => (ranks.get(a) ?? 1) - (ranks.get(b) ?? 1));
return normalizeSuggestions(ranked).slice(0, MAX_SUGGESTIONS);
},
getGroups: () => {