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

- +