Files
webmail/lib/search-suggestions.ts
T

95 lines
3.2 KiB
TypeScript

// Pure helpers behind the search-bar suggestion dropdown (#845): merging
// recent searches with contact/sender matches, and splitting a label into
// highlighted / plain segments for the matched substring.
export interface ContactSuggestion {
name: string;
email: string;
}
export type SearchSuggestion =
| { kind: "recent"; query: string }
| { kind: "contact"; name: string; email: string };
/** Recent searches shown when the search bar is focused but empty. */
export const MAX_RECENT_IDLE = 5;
/** Recent searches shown alongside contact matches while typing. */
export const MAX_RECENT_TYPING = 3;
/** Contact / sender matches shown while typing. */
export const MAX_CONTACT_SUGGESTIONS = 5;
/**
* Build the flat, keyboard-navigable suggestion list for a query.
*
* - Empty query: the most recent searches only.
* - Non-empty query: recent searches containing the query (minus an exact
* match, which would just re-run what is already typed), followed by
* contact matches. Contact groups (no address) are dropped — a sender
* filter needs a concrete address — and duplicate addresses collapse.
*/
export function buildSearchSuggestions(
query: string,
recentSearches: readonly string[],
contacts: readonly ContactSuggestion[],
): SearchSuggestion[] {
const q = query.trim().toLowerCase();
const suggestions: SearchSuggestion[] = [];
if (!q) {
for (const recent of recentSearches.slice(0, MAX_RECENT_IDLE)) {
suggestions.push({ kind: "recent", query: recent });
}
return suggestions;
}
let recentCount = 0;
for (const recent of recentSearches) {
if (recentCount >= MAX_RECENT_TYPING) break;
const lower = recent.toLowerCase();
if (lower === q || !lower.includes(q)) continue;
suggestions.push({ kind: "recent", query: recent });
recentCount++;
}
const seen = new Set<string>();
for (const contact of contacts) {
if (seen.size >= MAX_CONTACT_SUGGESTIONS) break;
const email = contact.email.trim();
if (!email) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
suggestions.push({ kind: "contact", name: contact.name.trim(), email });
}
return suggestions;
}
export interface HighlightSegment {
text: string;
match: boolean;
}
/**
* Split `text` into segments, flagging every case-insensitive occurrence of
* `query` so the UI can emphasise the matched substring. An empty query yields
* the whole text as a single non-matching segment.
*/
export function splitHighlight(text: string, query: string): HighlightSegment[] {
const needle = query.trim().toLowerCase();
if (!needle || !text) return [{ text, match: false }];
const segments: HighlightSegment[] = [];
const haystack = text.toLowerCase();
let cursor = 0;
let idx = haystack.indexOf(needle, cursor);
while (idx !== -1) {
if (idx > cursor) segments.push({ text: text.slice(cursor, idx), match: false });
segments.push({ text: text.slice(idx, idx + needle.length), match: true });
cursor = idx + needle.length;
idx = haystack.indexOf(needle, cursor);
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), match: false });
return segments;
}