mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
95 lines
3.2 KiB
TypeScript
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;
|
|
}
|