mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
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:
+96
-53
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user