From e7466175efa0d4c85664e8e6881b8c22697e4977 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Thu, 11 Jun 2026 19:33:55 +0900 Subject: [PATCH] Email library chip-bar: filter + tag suggestions with their icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a filter keyword now surfaces the matching filter row in the autocomplete (each with its existing dropdown icon). Picking one pins a filter pill and drives the global filter state. Keyword catalog (_LIB_FILTER_OPTIONS): - has-attachments ← 'attachment', 'attachments', 'has attachment', 'attach' - unread ← 'unread', 'new', 'unseen' - favorites ← 'favorite', 'starred', 'star', 'flagged' - undone ← 'undone', 'pending', 'todo' - reminders ← 'reminder', 'reminders' - unanswered ← 'unanswered', 'unreplied', 'no reply' - pending_30d ← 'pending 30d', 'pending', 'recent pending' - stale_30d ← 'stale', 'old', 'stale 30d' - tag:urgent ← 'urgent', 'critical' - tag:reply-soon ← 'reply soon', 'reply', 'follow up' - tag:spam ← 'spam', 'junk' - tag:newsletter ← 'newsletter', 'newsletters', 'subscriptions' - tag:marketing ← 'marketing', 'promo', 'promotional' Filter pill behaviour: - Only one filter pill is active at a time — adding a new one replaces any existing filter pill. - _applyFilterPillSideEffect drives the existing #email-lib-filter select (or the #email-attach-btn toggle for has-attachments). The server-side list refetch follows for free via the existing 'change' handler. - Removing the filter pill clears the side effect. Pill render gains the filter icon as a leading glyph; the suggestion row renders icon + label in the accent colour so it visually reads as a filter, not a contact. --- static/js/emailLibrary.js | 141 ++++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 13 deletions(-) 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 `