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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 20:26:26 +02:00
1 parent f465306c1d
commit 00d14b3015
3 files changed
+47 -22

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.3.1'); define('APP_VERSION', '1.3.2');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.3.1", "version": "1.3.2",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+45 -20
View File
@@ -113,16 +113,26 @@ function showView(name) {
// the moment the user navigates away (account/admin/login) so we don't // the moment the user navigates away (account/admin/login) so we don't
// hammer the server in the background. // hammer the server in the background.
let inboxPollHandle = null; let inboxPollHandle = null;
let inboxBusy = false;
function startInboxPoll() { function startInboxPoll() {
stopInboxPoll(); stopInboxPoll();
inboxPollHandle = setInterval(() => { inboxPollHandle = setInterval(() => {
if (!$('view-compose')?.classList.contains('hidden')) loadInbox(); if (inboxBusy) return;
if (!$('view-compose')?.classList.contains('hidden')) loadInbox({ quiet: true });
}, 15000); }, 15000);
} }
function stopInboxPoll() { function stopInboxPoll() {
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; } 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 /** Burger toggle for the mobile nav. The button is hidden on desktop
* via CSS, on mobile it expands #nav-buttons as a dropdown panel * via CSS, on mobile it expands #nav-buttons as a dropdown panel
* positioned under the header. */ * positioned under the header. */
@@ -371,18 +381,27 @@ async function sendCompose() {
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ── // ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
async function loadInbox() { let lastInboxJson = '';
async function loadInbox({ quiet = false } = {}) {
const list = $('inbox-list'); const list = $('inbox-list');
list.innerHTML = ` if (!quiet) {
<div class="skeleton-row"><div class="skel tall"></div></div> list.innerHTML = `
<div class="skeleton-row"><div class="skel short"></div></div> <div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel"></div></div> <div class="skeleton-row"><div class="skel short"></div></div>
`; <div class="skeleton-row"><div class="skel"></div></div>
`;
}
const res = await api('api/messages/inbox'); const res = await api('api/messages/inbox');
const messages = res?.messages || []; 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-count').textContent = messages.length;
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0); $('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 = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`; $('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
$('header-status').classList.toggle('has-unread', messages.length > 0); $('header-status').classList.toggle('has-unread', messages.length > 0);
@@ -408,6 +427,7 @@ async function loadInbox() {
} }
async function deleteMessage(id, btn) { async function deleteMessage(id, btn) {
inboxBusy = true;
// Inline confirmation: replace the trash button with yes/no. // Inline confirmation: replace the trash button with yes/no.
// Event listeners are wired via JS (not inline onclick) to avoid // Event listeners are wired via JS (not inline onclick) to avoid
// passing escaped HTML through attributes. // passing escaped HTML through attributes.
@@ -425,6 +445,7 @@ async function deleteMessage(id, btn) {
btn.replaceWith(wrap); btn.replaceWith(wrap);
wrap.querySelector('.del-yes').addEventListener('click', async () => { wrap.querySelector('.del-yes').addEventListener('click', async () => {
const res = await api('api/messages/' + id, { method: 'DELETE' }); const res = await api('api/messages/' + id, { method: 'DELETE' });
inboxBusy = false;
if (res?.ok) { if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); } if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
loadInbox(); loadInbox();
@@ -435,6 +456,7 @@ async function deleteMessage(id, btn) {
} }
}); });
wrap.querySelector('.del-no').addEventListener('click', () => { wrap.querySelector('.del-no').addEventListener('click', () => {
inboxBusy = false;
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.innerHTML = origHtml; temp.innerHTML = origHtml;
wrap.replaceWith(temp.firstChild); wrap.replaceWith(temp.firstChild);
@@ -443,18 +465,21 @@ async function deleteMessage(id, btn) {
window.deleteMessage = deleteMessage; window.deleteMessage = deleteMessage;
async function deleteAllMessages() { async function deleteAllMessages() {
const res = await api('api/messages/inbox'); inboxBusy = true;
const messages = res?.messages || []; try {
if (!messages.length) return; const res = await api('api/messages/inbox');
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return; const messages = res?.messages || [];
let ok = 0; if (!messages.length) return;
for (const m of messages) { if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
const r = await api('api/messages/' + m.id, { method: 'DELETE' }); let ok = 0;
if (r?.ok) ok++; for (const m of messages) {
} const r = await api('api/messages/' + m.id, { method: 'DELETE' });
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok'); if (r?.ok) ok++;
loadInbox(); }
refreshMe(); toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
} finally { inboxBusy = false; }
} }
window.deleteAllMessages = deleteAllMessages; window.deleteAllMessages = deleteAllMessages;