diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index f7cfb9472..bed503194 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -773,6 +773,9 @@ export function openEmailLibrary(opts = {}) { state._libEmails = []; state._libOffset = 0; state._libSearch = ''; + state._libSearchDraft = ''; + state._libSearchPills = []; + _libSuggestionCache = null; state._libFilter = 'all'; state._libHasAttachments = false; // Animate the very first card render with a domino cascade (same as the @@ -857,7 +860,11 @@ export function openEmailLibrary(opts = {}) {
- + + @@ -1081,16 +1088,11 @@ export function openEmailLibrary(opts = {}) { // \Flagged search). _libSort stays at its 'recent' default so the grid keeps // the API's newest-first order. - let searchTimer = null; - document.getElementById('email-lib-search').addEventListener('input', (e) => { - state._libSearch = e.target.value; - // Instant local filter so the grid responds on every keystroke even - // before the server-side IMAP search lands. The debounced server - // search still runs and replaces the grid when it returns. - _localSearchFilter(state._libSearch); - if (searchTimer) clearTimeout(searchTimer); - searchTimer = setTimeout(_doSearch, 350); - }); + // Chip-bar search: pills represent contact + free-text filters; the live + // input below drives the autocomplete dropdown. Old behavior — instant + // local filter on every keystroke + server-side IMAP search after 350ms + // — is replaced by deterministic local filtering against the snapshot. + _initEmailSearchChipBar(); document.getElementById('email-lib-refresh-btn').addEventListener('click', async () => { const btn = document.getElementById('email-lib-refresh-btn'); @@ -1664,6 +1666,85 @@ function _crossFolderCandidates() { let _libPreSearchEmails = null; let _libPreSearchTotal = 0; +// Cached contact suggestions for the chip-input autocomplete. Built on +// first focus / first keystroke from contacts + currently-loaded senders. +let _libSuggestionCache = null; +let _libSuggestionFocusIdx = 0; + +async function _buildSuggestionSource() { + // Combine the contacts list with senders/recipients visible in the + // loaded email list. Dedup by lowercased email address; prefer + // contact-supplied display names where present. + const map = new Map(); + const _add = (name, email) => { + const key = String(email || '').trim().toLowerCase(); + if (!key) return; + const prev = map.get(key); + if (!prev || (name && !prev.name)) { + map.set(key, { name: (name || '').trim(), email: key }); + } + }; + // 1) Senders / recipients already in the loaded grid. + for (const em of (state._libEmails || [])) { + _add(em.from_name, em.from_address); + const _parse = (s) => String(s || '').split(',').forEach(seg => { + const m = seg.match(/^\s*"?([^"<]*)"?\s*]+)>?\s*$/); + if (m) _add(m[1], m[2]); + }); + _parse(em.to); + _parse(em.cc); + } + // 2) Address book — best-effort. + try { + const r = await fetch(`${API_BASE}/api/contacts/list`, { credentials: 'same-origin' }); + if (r.ok) { + const d = await r.json(); + for (const c of (d.contacts || [])) { + const email = c.email || (c.emails && c.emails[0]) || ''; + _add(c.name || c.full_name, email); + } + } + } catch (_) {} + return Array.from(map.values()).filter(x => x.email); +} + +function _scoreSuggestion(s, needle) { + // Crude relevance: startsWith on name or email wins big; substring is fine. + const n = (s.name || '').toLowerCase(); + const e = (s.email || '').toLowerCase(); + if (n.startsWith(needle) || e.startsWith(needle)) return 3; + if (n.includes(needle) || e.includes(needle)) return 2; + return 0; +} + +function _filterSuggestions(needle, limit = 6) { + const n = String(needle || '').trim().toLowerCase(); + if (!n) return []; + const src = _libSuggestionCache || []; + return src + .map(s => ({ s, score: _scoreSuggestion(s, n) })) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(x => x.s); +} + +function _emailMatchesPill(em, pill) { + if (!pill) return false; + if (pill.type === 'contact') { + const target = (pill.email || '').toLowerCase(); + if (!target) return false; + if (String(em.from_address || '').toLowerCase() === target) return true; + if (String(em.to || '').toLowerCase().includes(target)) return true; + if (String(em.cc || '').toLowerCase().includes(target)) return true; + return false; + } + // text pill — broad local-match + const q = (pill.text || '').toLowerCase(); + if (!q) return true; + return _matchesQuery(em, q); +} + function _matchesQuery(em, q) { const needle = q.toLowerCase(); return ( @@ -1674,18 +1755,22 @@ function _matchesQuery(em, q) { ); } -// Instant client-side filter — fires on every keystroke. Filters the -// pre-search snapshot so the user sees something immediately even -// though the server search hasn't returned yet. -function _localSearchFilter(query) { - const q = (query || '').trim(); - // First non-empty keystroke after an empty search: take the snapshot. - if (q.length >= 1 && !_libPreSearchEmails) { - _libPreSearchEmails = (state._libEmails || []).slice(); - _libPreSearchTotal = state._libTotal; +// Apply the active pill filter to the snapshot. Each pill is OR-ed; an +// email shows up if ANY pill matches (a contact pill matches by from/to/cc +// equality, a text pill matches by the broad _matchesQuery substring). +function _applyPillFilter() { + const pills = state._libSearchPills || []; + const draft = (state._libSearchDraft || '').trim(); + const noPills = pills.length === 0; + const noDraft = draft.length === 0; + // First time we apply with anything active: snapshot the loaded list. + if (!noPills || draft.length >= 1) { + if (!_libPreSearchEmails) { + _libPreSearchEmails = (state._libEmails || []).slice(); + _libPreSearchTotal = state._libTotal; + } } - if (q.length === 0) { - // Cleared — restore. + if (noPills && noDraft) { if (_libPreSearchEmails) { state._libEmails = _libPreSearchEmails; state._libTotal = _libPreSearchTotal; @@ -1695,15 +1780,210 @@ function _localSearchFilter(query) { _renderGrid(); return; } - if (q.length < 2) { - _renderGrid(); - return; - } const source = _libPreSearchEmails || state._libEmails || []; - const filtered = source.filter(em => _matchesQuery(em, q)); + const draftPill = draft.length >= 1 ? { type: 'text', text: draft } : null; + const effective = draftPill ? pills.concat([draftPill]) : pills; + const filtered = source.filter(em => effective.some(p => _emailMatchesPill(em, p))); state._libEmails = filtered; _renderGrid(); } +// Back-compat shim: older call sites still expect _localSearchFilter. +function _localSearchFilter(query) { + state._libSearchDraft = String(query || ''); + _applyPillFilter(); +} + +// Render the active pills inside the chip bar. Each pill carries a × to +// remove individually. Backspace on empty input also pops the last one. +function _renderSearchPills() { + const wrap = document.getElementById('email-lib-pills'); + if (!wrap) return; + const pills = state._libSearchPills || []; + const esc = s => String(s || '').replace(/&/g, '&').replace(/ { + const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || ''); + return ``; + }).join(''); + wrap.querySelectorAll('.email-lib-pill-x').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = Number(btn.dataset.pillIdx); + if (Number.isFinite(idx)) _removeSearchPillAt(idx); + }); + }); +} + +function _addSearchPill(pill) { + if (!pill) return; + if (!Array.isArray(state._libSearchPills)) state._libSearchPills = []; + // Dedup by email (contact) or text (text pill). + if (pill.type === 'contact') { + const key = (pill.email || '').toLowerCase(); + if (!key) return; + if (state._libSearchPills.some(p => p.type === 'contact' && (p.email || '').toLowerCase() === key)) return; + } else if (pill.type === 'text') { + const t = (pill.text || '').toLowerCase(); + if (!t) return; + if (state._libSearchPills.some(p => p.type === 'text' && (p.text || '').toLowerCase() === t)) return; + } + state._libSearchPills.push(pill); + _renderSearchPills(); + _applyPillFilter(); +} + +function _removeSearchPillAt(idx) { + if (!Array.isArray(state._libSearchPills)) return; + state._libSearchPills.splice(idx, 1); + _renderSearchPills(); + _applyPillFilter(); +} + +// Render the autocomplete dropdown below the input. focusIdx highlights +// the active row; Tab autocompletes / Enter accepts that row. +function _renderSearchSuggestions(items) { + const menu = document.getElementById('email-lib-suggest'); + if (!menu) return; + if (!items.length) { menu.style.display = 'none'; menu.innerHTML = ''; return; } + const esc = s => String(s || '').replace(/&/g, '&').replace(/ ` + + `).join(''); + menu.style.display = ''; + menu.querySelectorAll('.email-lib-suggest-item').forEach(row => { + row.addEventListener('mousedown', (e) => { + // mousedown (not click) so we beat the input blur handler that hides the menu. + e.preventDefault(); + const idx = Number(row.dataset.idx); + const item = items[idx]; + if (item) _acceptSuggestion(item); + }); + }); +} + +function _hideSearchSuggestions() { + const menu = document.getElementById('email-lib-suggest'); + if (menu) { menu.style.display = 'none'; menu.innerHTML = ''; } + _libSuggestionFocusIdx = 0; +} + +function _acceptSuggestion(s) { + const input = document.getElementById('email-lib-search'); + _addSearchPill({ type: 'contact', name: s.name, email: s.email }); + if (input) input.value = ''; + state._libSearchDraft = ''; + _hideSearchSuggestions(); + _applyPillFilter(); + if (input) input.focus(); +} + +async function _initEmailSearchChipBar() { + const bar = document.getElementById('email-lib-chip-bar'); + const input = document.getElementById('email-lib-search'); + if (!bar || !input) return; + state._libSearchPills = state._libSearchPills || []; + state._libSearchDraft = ''; + _renderSearchPills(); + + // Lazy-load suggestion source on first focus / keystroke. + const _ensureSuggestionCache = async () => { + if (_libSuggestionCache) return; + _libSuggestionCache = await _buildSuggestionSource(); + }; + + // Click anywhere in the bar lands the cursor in the input field. + bar.addEventListener('click', (e) => { + if (e.target.closest('.email-lib-pill-x')) return; + input.focus(); + }); + + let _itemsRef = []; + const _refreshSuggestions = async () => { + await _ensureSuggestionCache(); + _itemsRef = _filterSuggestions(input.value); + _libSuggestionFocusIdx = 0; + _renderSearchSuggestions(_itemsRef); + }; + + input.addEventListener('focus', _refreshSuggestions); + input.addEventListener('input', async () => { + state._libSearchDraft = input.value; + await _refreshSuggestions(); + _applyPillFilter(); + }); + input.addEventListener('blur', () => { + // Delay so click/mousedown on a suggestion fires first. + setTimeout(_hideSearchSuggestions, 120); + }); + input.addEventListener('keydown', (e) => { + const menu = document.getElementById('email-lib-suggest'); + const menuOpen = menu && menu.style.display !== 'none'; + if (e.key === 'Backspace' && !input.value && (state._libSearchPills || []).length) { + e.preventDefault(); + _removeSearchPillAt(state._libSearchPills.length - 1); + return; + } + if (e.key === 'ArrowDown' && menuOpen) { + e.preventDefault(); + _libSuggestionFocusIdx = Math.min(_libSuggestionFocusIdx + 1, _itemsRef.length - 1); + _renderSearchSuggestions(_itemsRef); + return; + } + if (e.key === 'ArrowUp' && menuOpen) { + e.preventDefault(); + _libSuggestionFocusIdx = Math.max(_libSuggestionFocusIdx - 1, 0); + _renderSearchSuggestions(_itemsRef); + return; + } + if (e.key === 'Tab' && menuOpen && _itemsRef[_libSuggestionFocusIdx]) { + e.preventDefault(); + _acceptSuggestion(_itemsRef[_libSuggestionFocusIdx]); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (menuOpen && _itemsRef[_libSuggestionFocusIdx]) { + _acceptSuggestion(_itemsRef[_libSuggestionFocusIdx]); + return; + } + // No autocomplete match — fall back to a free-text pill. + const v = input.value.trim(); + if (v) { + _addSearchPill({ type: 'text', text: v }); + input.value = ''; + state._libSearchDraft = ''; + _hideSearchSuggestions(); + } + return; + } + if (e.key === 'Escape') { + _hideSearchSuggestions(); + } + }); +} + +// Click-to-add: if a recipient chip in the email reader is clicked, drop +// the person into the library search as a contact pill so the user can +// pivot to "everything from / to this person" in one tap. +window.addEventListener('click', (e) => { + const chip = e.target.closest && e.target.closest('.recipient-chip'); + if (!chip) return; + // Only hijack inside the email reader area; ignore composer / forms etc. + if (!chip.closest('.email-reader-header, .email-card-reader, .email-reader-tab-modal')) return; + const email = (chip.dataset && chip.dataset.email) || ''; + const name = (chip.dataset && chip.dataset.name) || (chip.textContent || '').trim(); + if (!email) return; + e.preventDefault(); + e.stopPropagation(); + // Surface the library window if it's hidden, then drop a pill in. + try { window.openEmailLibrary && window.openEmailLibrary(); } catch (_) {} + _addSearchPill({ type: 'contact', name, email }); +}, true); async function _doSearch() { const seq = ++_libSearchSeq;