From 00d14b301505f51680b71be6f402046c1516934f Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 20:26:26 +0200 Subject: [PATCH] relay: suppress poll re-renders during user actions Inbox poll continues fetching but skips DOM updates when user is typing, deleting, or confirming. Quiet mode diffs message IDs to avoid unnecessary re-renders. Focus/blur tracking on all inputs auto-guards. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- electron/package.json | 2 +- relay/ui/app.js | 65 ++++++++++++++++++++++++++++++------------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/config.php b/config.php index 54de1f5..c6b0ec6 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { - if (!$('view-compose')?.classList.contains('hidden')) loadInbox(); + if (inboxBusy) return; + if (!$('view-compose')?.classList.contains('hidden')) loadInbox({ quiet: true }); }, 15000); } function stopInboxPoll() { if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; } } +// Suppress poll re-renders while the user is typing in any input/textarea +document.addEventListener('focusin', e => { + if (e.target.matches('input, textarea, select')) inboxBusy = true; +}); +document.addEventListener('focusout', e => { + if (e.target.matches('input, textarea, select')) inboxBusy = false; +}); + /** Burger toggle for the mobile nav. The button is hidden on desktop * via CSS, on mobile it expands #nav-buttons as a dropdown panel * positioned under the header. */ @@ -371,18 +381,27 @@ async function sendCompose() { // ── Inbox (counts only — content is encrypted for the hyprcrd client) ── -async function loadInbox() { +let lastInboxJson = ''; + +async function loadInbox({ quiet = false } = {}) { const list = $('inbox-list'); - list.innerHTML = ` -
-
-
- `; + if (!quiet) { + list.innerHTML = ` +
+
+
+ `; + } const res = await api('api/messages/inbox'); const messages = res?.messages || []; + + // In quiet mode, skip DOM update if nothing changed + const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched)); + if (quiet && json === lastInboxJson) return; + lastInboxJson = json; + $('inbox-count').textContent = messages.length; $('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0); - // Mirror count into the header status so the unread dot stays in sync $('header-status').innerHTML = `${messages.length} in inbox`; $('header-status').classList.toggle('has-unread', messages.length > 0); @@ -408,6 +427,7 @@ async function loadInbox() { } async function deleteMessage(id, btn) { + inboxBusy = true; // Inline confirmation: replace the trash button with yes/no. // Event listeners are wired via JS (not inline onclick) to avoid // passing escaped HTML through attributes. @@ -425,6 +445,7 @@ async function deleteMessage(id, btn) { btn.replaceWith(wrap); wrap.querySelector('.del-yes').addEventListener('click', async () => { const res = await api('api/messages/' + id, { method: 'DELETE' }); + inboxBusy = false; if (res?.ok) { if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); } loadInbox(); @@ -435,6 +456,7 @@ async function deleteMessage(id, btn) { } }); wrap.querySelector('.del-no').addEventListener('click', () => { + inboxBusy = false; const temp = document.createElement('div'); temp.innerHTML = origHtml; wrap.replaceWith(temp.firstChild); @@ -443,18 +465,21 @@ async function deleteMessage(id, btn) { window.deleteMessage = deleteMessage; async function deleteAllMessages() { - const res = await api('api/messages/inbox'); - const messages = res?.messages || []; - if (!messages.length) return; - if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return; - let ok = 0; - for (const m of messages) { - const r = await api('api/messages/' + m.id, { method: 'DELETE' }); - if (r?.ok) ok++; - } - toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok'); - loadInbox(); - refreshMe(); + inboxBusy = true; + try { + const res = await api('api/messages/inbox'); + const messages = res?.messages || []; + if (!messages.length) return; + if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return; + let ok = 0; + for (const m of messages) { + const r = await api('api/messages/' + m.id, { method: 'DELETE' }); + if (r?.ok) ok++; + } + toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok'); + loadInbox(); + refreshMe(); + } finally { inboxBusy = false; } } window.deleteAllMessages = deleteAllMessages;