mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
fix: suggest recipients whose name starts with what you type first
This commit is contained in:
@@ -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
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user