From c16659c2ad0d8924c529395228f6fa45ecffddbc Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 09:09:29 +0200 Subject: [PATCH] new-card unread dot + pulse fix, relay inbox readable, UI polish pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board: new-card unread indicator (public/js/modules/board.js, public/js/modules/buckets.js, public/js/modules/state.js, public/css/style.css): * state.unseenCardIds (Set) tracks card ids that arrived since the last visit to the "all" view. Diff runs in renderBoard() against a module-private knownCardIds snapshot. * Bug fix: the first renderBoard runs with state.cards = [] (before the API returns). The previous diff treated every card from the real load as "new" and pulsed the entire board. Now the diff is skipped when knownCardIds is null or empty — the first non-empty render is a silent prime. * When activeBucket === 'all' and unseenCardIds is non-empty, the set is drained and every matching board card gets a 3-cycle accent glow (`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works both when navigating TO all and when a poll arrives while already on all. * When the user is on a different bucket: unseenCardIds accumulates across polls and a red dot (`.bar-unread-dot`) appears on the "all" tab. Position is top:3px right:3px *inside* the tab to survive the double overflow clip (`#main-header overflow:hidden` + `#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the all-tab template on every render so it survives bar re-renders that aren't followed by a board render. * decorateAllUnreadDot() in board.js is a fallback called at the end of every renderBoard for cases where the diff changed unseenCardIds but renderBar hasn't run yet. Relay: readable inbox (relay/inc/routes/messages.php, relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css): * New session-based GET /api/messages/inbox — returns up to 200 messages for the logged-in user, decrypted server-side with the inter-tenant key (no extra leakage — the relay already sees plaintext during the routing hop). Does NOT mark messages as fetched; the hyprcrd client is still the canonical receiver. * loadInbox() now fetches from /api/messages/inbox and renders actual inbox items: sender (with user-circle icon + accent2), optional title (bold), body (pre-wrap, max-height 120px), relative timestamp (formatRelTime: "just now", "2 min ago", "today 14:32", "3 d ago", or YYYY-MM-DD for older). Unfetched messages show a small "new" accent badge next to the sender name. * deleteMessage(id, btn) — confirms, calls existing DELETE /api/messages/{id} (already session-aware), removes the DOM row, re-loads the list, toasts "message deleted". * Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every 15s while the compose view is active. Stops on view switch. * Header-status badge: mirrors the real message count (not just the /api/status count) and toggles the has-unread class + dot so the tap-to-scroll-to-inbox target stays fresh. Relay UI polish (relay/ui/style.css): * .header-status is now a clickable button with tray icon, hover tint, focus-ring, and a red dot (::after pseudo) when has-unread. jumpToInbox() scrolls smoothly to #inbox-section and flashes it with a 1.6s accent box-shadow pulse. * .inbox-item .from is flex-row with icon + badge-new pill. * .inbox-item .del gets hover-background for better mobile tappability. * @keyframes flash-highlight for the scroll-to-inbox animation. chore: bump version to 0.9.9 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/css/style.css | 45 +++ public/js/modules/board.js | 66 ++++ public/js/modules/buckets.js | 8 +- public/js/modules/state.js | 5 + relay/inc/routes/messages.php | 29 ++ relay/ui/app.js | 171 ++++++++- relay/ui/layout.php | 49 +-- relay/ui/style.css | 644 +++++++++++++++++++++++----------- 9 files changed, 773 insertions(+), 246 deletions(-) diff --git a/config.php b/config.php index 23304c5..4ca7d33 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { + for (const id of ids) { + const el = board.querySelector(`[data-id="${id}"]`); + if (!el) continue; + el.classList.add('card-pulse-new'); + setTimeout(() => el.classList.remove('card-pulse-new'), 2400); + } + }); +} + +/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden + * while the user is currently looking at the all view. Lives in + * board.js (rather than buckets.js) because the trigger is "new card + * arrived" — buckets.js doesn't know about that, board.js does. */ +function decorateAllUnreadDot() { + const allTab = document.querySelector('#bucket-bar [data-bucket="all"]'); + if (!allTab) return; + const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all'; + allTab.classList.toggle('has-unread', hasUnread); + let dot = allTab.querySelector('.bar-unread-dot'); + if (hasUnread && !dot) { + dot = document.createElement('span'); + dot.className = 'bar-unread-dot'; + allTab.appendChild(dot); + } else if (!hasUnread && dot) { + dot.remove(); + } +} + // ── Multi-select ── const selectedIds = state.selectedIds; const selectBar = $('select-bar'); @@ -450,6 +492,19 @@ export function renderCardHtml(c, inGroup = false, recent = []) { // ── Render ── export function renderBoard() { + // Diff state.cards against the last seen ids so we can flag arrivals + // as "unseen". The very first renderBoard runs with state.cards = [] + // (initial empty state, before the API has returned), so we wait + // until knownCardIds is non-empty before doing the diff — otherwise + // the first real load looks like "every card just arrived" and the + // entire board pulses on page open. + const currentIds = new Set(state.cards.map(c => String(c.id))); + if (knownCardIds && knownCardIds.size > 0) { + for (const id of currentIds) { + if (!knownCardIds.has(id)) state.unseenCardIds.add(id); + } + } + knownCardIds = currentIds; let cards; if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); @@ -686,6 +741,17 @@ export function renderBoard() { // Notification banner renderNotifs(); + + // Unread tracking: if we're on the all view, drain the unseen set + // by pulsing every matching card on the board (the "look at me" + // accent flash). If we're on a different view, the unseen ids stay + // accumulated and the all-tab gets a dot until the user opens it. + if (state.activeBucket === 'all' && state.unseenCardIds.size) { + const toPulse = new Set(state.unseenCardIds); + state.unseenCardIds.clear(); + pulseCardsNew(toPulse); + } + decorateAllUnreadDot(); } // ── Notification providers ── diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index d36e13b..91e5a22 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -264,8 +264,14 @@ function renderBar() { return parent; }; + // Show an unread dot on the fixed `all` tab when state.unseenCardIds + // has anything in it AND the user isn't currently looking at the + // all view. board.js drives the unseen set; we just read it here so + // the dot survives bar re-renders that aren't followed by a board + // render (e.g. bucket reorder, plain renderBar() call). + const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0; bar.innerHTML = ` - + diff --git a/public/js/modules/state.js b/public/js/modules/state.js index 8ef7ac0..a8ef035 100644 --- a/public/js/modules/state.js +++ b/public/js/modules/state.js @@ -32,6 +32,11 @@ export const state = { // pick the title strategy: false → first selected wins, true → // longest title wins (since "first" isn't meaningful for bulk). selectionWasBulk: false, + // Card ids that have arrived since the user last entered the "all" + // view. Drives the unread dot on the all bucket-tab and the brief + // accent pulse on the cards themselves once the user looks at them. + // Updated by board.js renderBoard() — see the diff there. + unseenCardIds: new Set(), // Detail / editor editingId: null, diff --git a/relay/inc/routes/messages.php b/relay/inc/routes/messages.php index ce25064..9035d07 100644 --- a/relay/inc/routes/messages.php +++ b/relay/inc/routes/messages.php @@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty( jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]); } +// ── UI inbox view: session reads decrypted messages ── +// +// The relay already sees plaintext during the routing hop (decrypt with +// sender secret → re-encrypt with inter-tenant key for at-rest storage), +// so reading them back to the session user is no extra leakage. Lets the +// user check their inbox from the relay web UI on a phone without +// needing the full hyprcrd client. Does NOT mark messages as fetched — +// the canonical receiver is still the hyprcrd client. + +if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) { + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); + $stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200'); + $stmt->execute(['r' => $sessionTenant['id']]); + $rows = $stmt->fetchAll(); + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'id' => (int)$row['id'], + 'sender' => $row['sender_username'] ?? '?', + 'title' => clientDecrypt($row['title'] ?? '', $interKey), + 'body' => clientDecrypt($row['body'] ?? '', $interKey), + 'created_at' => $row['created_at'], + 'fetched' => (int)$row['fetched'], + ]; + } + jsonResponse(['messages' => $out]); +} + // ── Client API auth (X-Relay-User + X-Relay-Key) ── $clientTenant = authClient($db); diff --git a/relay/ui/app.js b/relay/ui/app.js index 9d0ae2d..f31b6f2 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -5,12 +5,29 @@ let allTenants = []; function $(id) { return document.getElementById(id); } -function toast(msg, isError = false) { - const t = $('toast'); - t.textContent = msg; - t.classList.toggle('err', isError); - t.classList.add('show'); - setTimeout(() => t.classList.remove('show'), 2200); +/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'. + * Multiple toasts stack vertically, oldest at the top, and each one + * slides in from the right + fades out after a fixed timeout. The + * legacy `toast(msg, true)` second-arg style still works (true → err). */ +function toast(msg, kind = 'info') { + if (kind === true) kind = 'err'; + if (kind === false) kind = 'info'; + const stack = $('toast-stack'); + if (!stack) return; + const el = document.createElement('div'); + el.className = 'toast ' + kind; + const icon = kind === 'err' ? 'ph-warning-circle' + : kind === 'ok' ? 'ph-check-circle' + : 'ph-info'; + el.innerHTML = ``; + el.querySelector('.toast-msg').textContent = msg; + stack.appendChild(el); + // Trigger the slide-in next frame so the transition runs + requestAnimationFrame(() => el.classList.add('show')); + setTimeout(() => { + el.classList.add('fade-out'); + setTimeout(() => el.remove(), 350); + }, 2800); } async function api(path, opts = {}) { @@ -52,8 +69,12 @@ function showView(name) { if (name === 'compose') { loadTenants().then(() => populateRecipientPicker()); loadInbox(); + startInboxPoll(); $('compose-body')?.focus(); - } else if (name === 'admin') { + } else { + stopInboxPoll(); + } + if (name === 'admin') { loadUsers(); } else if (name === 'account') { loadAccountSecret(); @@ -63,6 +84,23 @@ function showView(name) { } } +// ── Inbox auto-poll ── +// +// Refresh the inbox every 15s while the compose view is showing so new +// messages from other tenants surface without a manual reload. Stops +// the moment the user navigates away (account/admin/login) so we don't +// hammer the server in the background. +let inboxPollHandle = null; +function startInboxPoll() { + stopInboxPoll(); + inboxPollHandle = setInterval(() => { + if (!$('view-compose')?.classList.contains('hidden')) loadInbox(); + }, 15000); +} +function stopInboxPoll() { + if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; } +} + /** 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. */ @@ -70,6 +108,31 @@ function toggleNavMenu() { $('nav-buttons')?.classList.toggle('menu-open'); } window.toggleNavMenu = toggleNavMenu; + +/** Tap-target on the header inbox count: switches to the compose view + * if needed, then smooth-scrolls #inbox-section into view and briefly + * highlights it so the user can't miss it after the scroll lands. + * Same handler on desktop and mobile — desktop just scrolls a few px + * if at all (the inbox is already on screen). */ +function jumpToInbox() { + if (!me) return; + if ($('view-compose')?.classList.contains('hidden')) { + showView('compose'); + } + // Wait one frame so the (possibly just-shown) view has measurable + // dimensions before scrollIntoView fires + requestAnimationFrame(() => { + const target = $('inbox-section'); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target.classList.remove('flash-highlight'); + // Restart the animation by toggling the class + void target.offsetWidth; + target.classList.add('flash-highlight'); + setTimeout(() => target.classList.remove('flash-highlight'), 1600); + }); +} +window.jumpToInbox = jumpToInbox; // Tap outside the menu closes it document.addEventListener('click', (e) => { const nav = $('nav-buttons'); @@ -188,7 +251,9 @@ async function refreshMe() { }; $('nav-user').innerHTML = `${escapeHtml(me.username)}` + (me.is_admin ? 'admin' : ''); $('nav-admin').classList.toggle('hidden', !me.is_admin); - $('header-status').textContent = (status.inbox_count || 0) + ' in inbox'; + const n = status.inbox_count || 0; + $('header-status').innerHTML = `${n} in inbox`; + $('header-status').classList.toggle('has-unread', n > 0); } return status; } @@ -263,31 +328,101 @@ async function sendCompose() { // ── Inbox (counts only — content is encrypted for the hyprcrd client) ── async function loadInbox() { - const status = await api('api/status'); - $('inbox-count').textContent = status.inbox_count || 0; const list = $('inbox-list'); - if (!status.inbox_count) { - list.innerHTML = '
no messages waiting. they will be picked up by the next hyprcrd poll.
'; - } else { - list.innerHTML = `
${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.
`; + list.innerHTML = ` +
+
+
+ `; + const res = await api('api/messages/inbox'); + const messages = res?.messages || []; + $('inbox-count').textContent = messages.length; + // 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); + + if (!messages.length) { + list.innerHTML = ` +
+ +
inbox empty
+
Messages from other tenants will show up here.
+
`; + return; } + list.innerHTML = messages.map(m => ` +
+
+
${escapeHtml(m.sender)}${m.fetched ? '' : ' new'}
+ ${m.title ? `
${escapeHtml(m.title)}
` : ''} +
${escapeHtml(m.body)}
+
+
${escapeHtml(formatRelTime(m.created_at))}
+ +
+ `).join(''); +} + +async function deleteMessage(id, btn) { + if (!confirm('Delete this message?')) return; + const res = await api('api/messages/' + id, { method: 'DELETE' }); + if (res?.ok) { + btn.closest('.inbox-item')?.remove(); + loadInbox(); + refreshMe(); + toast('message deleted', 'ok'); + } else { + toast(res?.error || 'delete failed', 'err'); + } +} +window.deleteMessage = deleteMessage; + +/** Lightweight relative-time formatter for the inbox. SQLite gives us + * `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32", + * or a full date for older messages. */ +function formatRelTime(sqliteUtc) { + if (!sqliteUtc) return ''; + // Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z + const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z'); + if (isNaN(t.getTime())) return sqliteUtc; + const diff = (Date.now() - t.getTime()) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + ' min ago'; + if (diff < 86400) { + const today = new Date(); + const sameDay = today.toDateString() === t.toDateString(); + if (sameDay) return 'today ' + t.toTimeString().slice(0, 5); + return Math.floor(diff / 3600) + ' h ago'; + } + if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago'; + return t.toISOString().slice(0, 10); } // ── Admin: users ── async function loadUsers() { + const list = $('users-list'); + list.innerHTML = ` +
+
+
+ `; const res = await api('api/users'); - if (res.error) { toast(res.error, true); return; } + if (res.error) { toast(res.error, 'err'); return; } const users = res.users || []; $('users-count').textContent = users.length; - const list = $('users-list'); if (!users.length) { - list.innerHTML = '
no users yet
'; + list.innerHTML = ` +
+ +
no users yet
+
Create the first one with the form on the right.
+
`; return; } list.innerHTML = users.map(u => `
- +
${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''}
created ${u.created_at}
diff --git a/relay/ui/layout.php b/relay/ui/layout.php index d273261..ddaa2d8 100644 --- a/relay/ui/layout.php +++ b/relay/ui/layout.php @@ -14,8 +14,9 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
+

hyprcrd relay

- +