diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 50c9ae627..a976516e1 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -1731,13 +1731,57 @@ function _scoreSuggestion(s, needle) { return 0; } -function _filterSuggestions(needle, limit = 6) { +// Filter / attachment suggestions surfaced inside the same chip-bar +// dropdown. Typing 'attachment', 'unread', 'urgent' etc. surfaces the +// corresponding filter row with its icon; picking it pins a filter +// pill that drives state._libFilter or the has-attachments toggle. +const _LIB_FILTER_OPTIONS = [ + { value: 'filter:has-attachments', label: 'Has attachments', keywords: ['attachment', 'attachments', 'has attachment', 'attach'] }, + { value: 'filter:unread', label: 'Unread', keywords: ['unread', 'new', 'unseen'] }, + { value: 'filter:favorites', label: 'Favorites', keywords: ['favorite', 'favorites', 'starred', 'star', 'flagged'] }, + { value: 'filter:undone', label: 'Undone', keywords: ['undone', 'pending', 'todo'] }, + { value: 'filter:reminders', label: 'Reminders', keywords: ['reminder', 'reminders'] }, + { value: 'filter:unanswered', label: 'Unanswered', keywords: ['unanswered', 'unreplied', 'no reply'] }, + { value: 'filter:pending_30d', label: 'Pending · 30d', keywords: ['pending 30d', 'pending', 'recent pending'] }, + { value: 'filter:stale_30d', label: 'Stale · >30d', keywords: ['stale', 'old', 'stale 30d'] }, + { value: 'filter:tag:urgent', label: 'Urgent', keywords: ['urgent', 'critical'] }, + { value: 'filter:tag:reply-soon', label: 'Reply soon', keywords: ['reply soon', 'reply', 'follow up'] }, + { value: 'filter:tag:spam', label: 'Spam', keywords: ['spam', 'junk'] }, + { value: 'filter:tag:newsletter', label: 'Newsletter', keywords: ['newsletter', 'newsletters', 'subscriptions'] }, + { value: 'filter:tag:marketing', label: 'Marketing', keywords: ['marketing', 'promo', 'promotional'] }, +]; + +function _libFilterIconFor(value) { + // value is 'filter:' — strip prefix and reuse the existing icon map. + const v = String(value || '').replace(/^filter:/, ''); + if (v === 'has-attachments') return ''; + return _EMAIL_FILTER_ICONS[v] || _EMAIL_FILTER_ICONS['all']; +} + +function _scoreFilterOption(opt, needle) { + for (const kw of opt.keywords) { + if (kw === needle) return 4; + if (kw.startsWith(needle)) return 3; + if (kw.includes(needle)) return 2; + } + if (opt.label.toLowerCase().includes(needle)) return 2; + return 0; +} + +function _filterSuggestions(needle, limit = 8) { const n = String(needle || '').trim().toLowerCase(); if (!n) return []; + // Filter / attachment matches first — typing 'unread' should surface + // the filter row before contact suggestions, since 'unread' isn't a + // person. + const filterMatches = _LIB_FILTER_OPTIONS + .map(opt => ({ s: { kind: 'filter', value: opt.value, label: opt.label, icon: _libFilterIconFor(opt.value) }, score: _scoreFilterOption(opt, n) })) + .filter(x => x.score > 0); const src = _libSuggestionCache || []; - return src - .map(s => ({ s, score: _scoreSuggestion(s, n) })) - .filter(x => x.score > 0) + const contactMatches = src + .map(s => ({ s: { kind: 'contact', ...s }, score: _scoreSuggestion(s, n) })) + .filter(x => x.score > 0); + return filterMatches.concat(contactMatches) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(x => x.s); @@ -1753,6 +1797,13 @@ function _emailMatchesPill(em, pill) { if (String(em.cc || '').toLowerCase().includes(target)) return true; return false; } + if (pill.type === 'filter') { + // Filter pills delegate to the server-side filter (state._libFilter) + // or the has-attachments toggle. The list is already pre-filtered by + // those when this runs, so the pill is effectively always-true here + // — it lives in the pill bar purely as a visible affordance. + return true; + } // text pill — broad local-match const q = (pill.text || '').toLowerCase(); if (!q) return true; @@ -1817,9 +1868,16 @@ function _renderSearchPills() { const pills = state._libSearchPills || []; const esc = s => String(s || '').replace(/&/g, '&').replace(/ { - const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || ''); - return ` - ${esc(label)} + let label = ''; + let leadingIcon = ''; + if (p.type === 'contact') label = p.name || p.email || '?'; + else if (p.type === 'filter') { + label = p.label || p.value; + leadingIcon = `${_libFilterIconFor(p.value)}`; + } + else label = p.text || ''; + return ``; }).join(''); @@ -1832,10 +1890,47 @@ function _renderSearchPills() { }); } +function _applyFilterPillSideEffect(pill) { + // Filter pills drive the existing has-attachments toggle / filter + // dropdown so the server returns the right list. Only one filter + // pill is active at a time (see _addSearchPill). + const sel = document.getElementById('email-lib-filter'); + const attachBtn = document.getElementById('email-attach-btn'); + if (pill.value === 'filter:has-attachments') { + if (!state._libHasAttachments) { + state._libHasAttachments = true; + if (attachBtn) attachBtn.classList.add('active'); + } + if (sel && sel.value !== 'all') { sel.value = 'all'; sel.dispatchEvent(new Event('change')); } + return; + } + // Any other filter pill — set the dropdown value, clear attachments + if (state._libHasAttachments) { + state._libHasAttachments = false; + if (attachBtn) attachBtn.classList.remove('active'); + } + if (sel) { + const v = pill.value.replace(/^filter:/, ''); + if (sel.value !== v) { sel.value = v; sel.dispatchEvent(new Event('change')); } + } +} + +function _clearFilterPillSideEffect() { + const sel = document.getElementById('email-lib-filter'); + const attachBtn = document.getElementById('email-attach-btn'); + if (state._libHasAttachments) { + state._libHasAttachments = false; + if (attachBtn) attachBtn.classList.remove('active'); + } + if (sel && sel.value !== 'all') { + sel.value = 'all'; sel.dispatchEvent(new Event('change')); + } +} + function _addSearchPill(pill) { if (!pill) return; if (!Array.isArray(state._libSearchPills)) state._libSearchPills = []; - // Dedup by email (contact) or text (text pill). + // Dedup by email (contact), text (text pill), or filter value. if (pill.type === 'contact') { const key = (pill.email || '').toLowerCase(); if (!key) return; @@ -1844,6 +1939,13 @@ function _addSearchPill(pill) { const t = (pill.text || '').toLowerCase(); if (!t) return; if (state._libSearchPills.some(p => p.type === 'text' && (p.text || '').toLowerCase() === t)) return; + } else if (pill.type === 'filter') { + // Single-filter rule — drop any existing filter pill before adding. + state._libSearchPills = state._libSearchPills.filter(p => p.type !== 'filter'); + state._libSearchPills.push(pill); + _applyFilterPillSideEffect(pill); + _renderSearchPills(); + return; } state._libSearchPills.push(pill); _renderSearchPills(); @@ -1852,7 +1954,9 @@ function _addSearchPill(pill) { function _removeSearchPillAt(idx) { if (!Array.isArray(state._libSearchPills)) return; + const removed = state._libSearchPills[idx]; state._libSearchPills.splice(idx, 1); + if (removed && removed.type === 'filter') _clearFilterPillSideEffect(); _renderSearchPills(); _applyPillFilter(); } @@ -1864,12 +1968,19 @@ function _renderSearchSuggestions(items) { 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) => { @@ -1890,7 +2001,11 @@ function _hideSearchSuggestions() { function _acceptSuggestion(s) { const input = document.getElementById('email-lib-search'); - _addSearchPill({ type: 'contact', name: s.name, email: s.email }); + if (s.kind === 'filter') { + _addSearchPill({ type: 'filter', value: s.value, label: s.label }); + } else { + _addSearchPill({ type: 'contact', name: s.name, email: s.email }); + } if (input) input.value = ''; state._libSearchDraft = ''; _hideSearchSuggestions();