From 127745d13bcd86586aeaf7745b9168724c3d9699 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Thu, 11 Jun 2026 08:28:25 +0900 Subject: [PATCH] Email search: instant local-cache filter + stop blanking the grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pain points: - IMAP server search is genuinely slow. - The grid blanked to a whirlpool on every keystroke, so even fast searches felt dead because you couldn't see your own results. Fix: - _localSearchFilter runs synchronously on every keystroke, filtering the pre-search snapshot by subject / from-name / from-address / snippet so the grid responds immediately. Snapshot is taken on the first non-empty keystroke and restored when the input is cleared. - _doSearch no longer renders the loading-whirlpool spinner into the grid. The local filter already shows useful results; surface 'Searching…' in the stats badge to indicate the server search is in flight. - When server results land, they replace the grid; if the user has already typed past them, the seq guard skips the stale render. --- static/js/emailLibrary.js | 65 ++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index f7104dbd3..0ed8fc13c 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -1071,6 +1071,10 @@ export function openEmailLibrary(opts = {}) { 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); }); @@ -1592,6 +1596,52 @@ function _crossFolderCandidates() { return Array.from(new Set(candidates.filter(Boolean))); } +// Snapshot of state._libEmails taken right before search starts so we +// can both filter locally and restore on clear without re-fetching. +let _libPreSearchEmails = null; +let _libPreSearchTotal = 0; + +function _matchesQuery(em, q) { + const needle = q.toLowerCase(); + return ( + String(em.subject || '').toLowerCase().includes(needle) || + String(em.from_name || '').toLowerCase().includes(needle) || + String(em.from_address || '').toLowerCase().includes(needle) || + String(em.snippet || em.preview || '').toLowerCase().includes(needle) + ); +} + +// 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; + } + if (q.length === 0) { + // Cleared — restore. + if (_libPreSearchEmails) { + state._libEmails = _libPreSearchEmails; + state._libTotal = _libPreSearchTotal; + _libPreSearchEmails = null; + _libPreSearchTotal = 0; + } + _renderGrid(); + return; + } + if (q.length < 2) { + _renderGrid(); + return; + } + const source = _libPreSearchEmails || state._libEmails || []; + const filtered = source.filter(em => _matchesQuery(em, q)); + state._libEmails = filtered; + _renderGrid(); +} + async function _doSearch() { const seq = ++_libSearchSeq; const q = state._libSearch.trim(); @@ -1607,17 +1657,19 @@ async function _doSearch() { _renderGrid(); return; } - const grid = document.getElementById('email-lib-grid'); - if (!grid) return; - const sp = _renderEmailLoading(grid); const accountAtStart = state._libAccountId || ''; const folderAtStart = state._libFolder || 'INBOX'; + // No grid-blanking spinner — the local filter already painted something + // useful. Surface progress in the stats badge instead so the user knows + // the server search is still grinding. + const stats = document.getElementById('email-lib-stats'); + const originalStatsText = stats?.textContent || ''; + if (stats) stats.textContent = 'Searching…'; try { const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : ''; const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`); const data = await res.json(); - sp.destroy(); if ( seq !== _libSearchSeq || q !== state._libSearch.trim() || @@ -1631,13 +1683,12 @@ async function _doSearch() { const results = data.emails || []; _libSearchHadResults = true; state._libEmails = results; // temporarily replace with search results + state._libTotal = data.total || results.length; _renderGrid(); - const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${data.total || results.length} match${(data.total || results.length) === 1 ? '' : 'es'}`; } catch (e) { - sp.destroy(); - grid.innerHTML = '
Search failed
'; + if (stats) stats.textContent = originalStatsText || 'Search failed'; } }