Email bulk actions: loading state for every action + 6-way parallel fetches

Before: only delete showed a spinner/disabled buttons. Picking Done on
92 selected emails fired off 184 sequential HTTP calls (mark-answered
+ mark-read) with zero UI feedback, so it looked like the click did
nothing for the ~20-30 seconds it took to grind through.

- All five bulk actions (delete / archive / done / read / unread) now
  swap the target button into a whirlpool+verb-ing state, dim siblings,
  and show 'N/M…' progress in the count label that ticks as each
  request resolves.
- Per-uid work runs in parallel with a hard cap of 6 in flight, so a
  90-email Done finishes in ~3 server round-trips of latency instead
  of 90, but we still don't open 90 simultaneous IMAP-backed connections.
This commit is contained in:
pewdiepie-archdaemon
2026-06-11 07:41:36 +09:00
parent 7c1af0385a
commit 5ec1e12a50
+96 -53
View File
@@ -4998,58 +4998,96 @@ async function _bulkAction(action) {
const originalDeleteHtml = deleteBtn?.innerHTML || ''; const originalDeleteHtml = deleteBtn?.innerHTML || '';
const originalCountText = countEl?.textContent || ''; const originalCountText = countEl?.textContent || '';
let busySpinner = null; let busySpinner = null;
if (action === 'delete') { // Loading state for every bulk action, not just delete — large
if (deleteBtn) { // selections (e.g. 90+ Dones) used to silently hammer the server
deleteBtn.disabled = true; // with sequential requests and the user got zero feedback. Now the
deleteBtn.classList.add('email-bulk-loading'); // Actions button (or Delete button) shows a whirlpool + verb-ing
deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>'; // label, and the count surfaces progress.
busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); const verbing = {
const spEl = busySpinner.createElement(); delete: 'Deleting',
spEl.classList.add('email-bulk-whirlpool'); archive: 'Archiving',
deleteBtn.appendChild(spEl); done: 'Marking done',
busySpinner.start(); read: 'Marking read',
} unread: 'Marking unread',
if (actionsBtn) actionsBtn.disabled = true; }[action] || 'Updating';
if (cancelBtn) cancelBtn.disabled = true; const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
if (selectAll) selectAll.disabled = true; let originalTargetHtml = '';
if (countEl) countEl.textContent = `Deleting ${uids.length}...`; if (targetBtn) {
originalTargetHtml = targetBtn.innerHTML;
targetBtn.disabled = true;
targetBtn.classList.add('email-bulk-loading');
targetBtn.innerHTML = `<span class="email-bulk-loading-label">${verbing}</span>`;
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('email-bulk-whirlpool');
targetBtn.appendChild(spEl);
busySpinner.start();
} }
if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `${verbing} ${uids.length}`;
// Single-uid worker.
const handleOne = async (uid) => {
try {
if (action === 'archive') {
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} else if (action === 'done') {
// uid may come back from the Set as a string while em.uid is
// numeric (or vice versa) — coerce both sides so the in-memory
// state actually flips and the post-loop re-render shows the
// done checkmark.
const em = state._libEmails.find(e => String(e.uid) === String(uid));
if (em) { em.is_answered = true; em.is_read = true; }
const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`);
} else if (action === 'read' || action === 'unread') {
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || data?.success === false) {
throw new Error(data?.error || `HTTP ${res.status}`);
}
_syncEmailReadState(uid, action === 'read');
}
} catch (e) {
if (action === 'read' || action === 'unread') failedReadSync += 1;
console.error(`Failed to ${action} ${uid}:`, e);
}
};
try { try {
for (const uid of uids) { // Run in parallel with a concurrency cap so 92 emails don't take
try { // 30 seconds sequentially but we also don't open 92 simultaneous
if (action === 'archive') { // connections.
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); const CONCURRENCY = 6;
} else if (action === 'delete') { const queue = uids.slice();
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); let inFlight = 0;
} else if (action === 'done') { let nextSlot = 0;
// uid may come back from the Set as a string while em.uid is let finishedCount = 0;
// numeric (or vice versa) — coerce both sides so the in-memory await new Promise((resolve) => {
// state actually flips and the post-loop re-render shows the const launch = () => {
// done checkmark. while (inFlight < CONCURRENCY && nextSlot < queue.length) {
const em = state._libEmails.find(e => String(e.uid) === String(uid)); const uid = queue[nextSlot++];
if (em) { inFlight++;
em.is_answered = true; handleOne(uid).finally(() => {
em.is_read = true; inFlight--;
} finishedCount++;
const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}`;
const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); if (nextSlot >= queue.length && inFlight === 0) resolve();
if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`); else launch();
} else if (action === 'read' || action === 'unread') { });
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || data?.success === false) {
throw new Error(data?.error || `HTTP ${res.status}`);
}
_syncEmailReadState(uid, action === 'read');
} }
} catch (e) { if (queue.length === 0) resolve();
if (action === 'read' || action === 'unread') failedReadSync += 1; };
console.error(`Failed to ${action} ${uid}:`, e); launch();
} });
}
if (action === 'archive' || action === 'delete') { if (action === 'archive' || action === 'delete') {
await _animateEmailCardRemoval(uids); await _animateEmailCardRemoval(uids);
@@ -5066,12 +5104,17 @@ async function _bulkAction(action) {
} }
} finally { } finally {
if (busySpinner) busySpinner.destroy(); if (busySpinner) busySpinner.destroy();
if (deleteBtn) { // Restore whichever button we hijacked (delete vs actions).
deleteBtn.disabled = false; if (targetBtn) {
deleteBtn.classList.remove('email-bulk-loading'); targetBtn.disabled = false;
deleteBtn.innerHTML = originalDeleteHtml; targetBtn.classList.remove('email-bulk-loading');
targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML;
} }
if (actionsBtn) actionsBtn.disabled = false; if (deleteBtn && deleteBtn !== targetBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
}
if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false; if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false; if (selectAll) selectAll.disabled = false;
if (countEl) countEl.textContent = originalCountText; if (countEl) countEl.textContent = originalCountText;