From 1966f8f4c00e00ae03be52104ca57ccf2eb89a0e Mon Sep 17 00:00:00 2001 From: th Date: Tue, 14 Apr 2026 23:00:30 +0200 Subject: [PATCH] sync-event toasts, image pull ordering, stale banner field diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace chat-bot sync notifications with a dedicated relay_sync_events queue: POST/GET /api/sync-events, drained by /api/relay/poll and surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed, bot=1 rows purged on bootstrap, orange bot rendering dropped from relay UI. Chat thread stays clean. - collab pull: fetch attachments BEFORE applying the body so the moment state.cards reflects the new reference the file is on disk. Fixes the "preview shows broken/missing image, editor shows it" race where the browser cached the 404 between attachment landing and user re-render. - detail stale banner now compares title/body/color/tags in addition to updated_at — robust against second-granularity timestamp collisions and any other case where updated_at doesn't tick but content differs. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- electron/package.json | 2 +- public/index.php | 34 +++++++++++--- public/js/modules/caldav.js | 30 ++++++++++++ public/js/modules/detail.js | 19 ++++++-- public/plugins/collab-buckets/backend.php | 56 +++++++++++++---------- relay/inc/bootstrap.php | 31 +++++++++++-- relay/inc/routes/chat.php | 50 ++++++++++++++------ relay/ui/app.js | 14 ++---- relay/ui/style.css | 7 --- 10 files changed, 170 insertions(+), 75 deletions(-) diff --git a/config.php b/config.php index 44d8609..7b5d141 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ true, CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $botBody]), + CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $relayUser, @@ -957,7 +957,27 @@ if (str_starts_with($route, '/api/')) { } } - echo json_encode(['ok' => true, 'pulled' => $pulled]); exit; + // Drain pending sync-event notifications so the JS caller can toast + // them. GET /api/sync-events on the relay auto-marks fetched events. + // Best-effort — older relays won't have the endpoint. + $syncEvents = []; + $sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events'); + curl_setopt_array($sch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 5, + ]); + $sResp = curl_exec($sch); + $sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE); + curl_close($sch); + if ($sStatus === 200) { + $decoded = json_decode($sResp, true); + if (is_array($decoded) && isset($decoded['events'])) { + $syncEvents = $decoded['events']; + } + } + + echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents]); exit; } // List relay tenants (for the recipient picker) diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js index d5b541e..829de4a 100644 --- a/public/js/modules/caldav.js +++ b/public/js/modules/caldav.js @@ -190,6 +190,7 @@ export async function triggerSync() { const relayRes = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.ok ? r.json() : null).catch(() => null); + drainSyncEvents(relayRes); if (res.ok) { const s = res.stats || {}; @@ -265,6 +266,7 @@ $('relay-test')?.addEventListener('click', async () => { if (res?.ok) { setRelayStatus(`ok — ${res.pulled} pulled`, 'success'); if (res.pulled) await window._reloadAll?.(); + drainSyncEvents(res); } else { const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : ''; setRelayStatus((res?.error || 'failed') + detail, 'error'); @@ -315,6 +317,33 @@ function showSyncNotification(stats) { setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000); } +// One toast per incoming sync event: "alice synced: Card Title". +// Stacks downward so a burst of multiple doesn't overlay each other. +let syncEventToastOffset = 0; +function showSyncEventToast(ev) { + const body = String(ev?.body || '').trim(); + if (!body) return; + const bottom = 110 + syncEventToastOffset * 48; + syncEventToastOffset++; + const note = document.createElement('div'); + note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`; + note.innerHTML = `
${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
`; + document.body.appendChild(note); + setTimeout(() => { + note.style.transition = 'opacity 0.3s'; + note.style.opacity = '0'; + setTimeout(() => { + note.remove(); + syncEventToastOffset = Math.max(0, syncEventToastOffset - 1); + }, 350); + }, 5000); +} + +function drainSyncEvents(relayRes) { + const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : []; + for (const ev of events) showSyncEventToast(ev); +} + async function backgroundSync() { if (state.bgSyncRunning) return; state.bgSyncRunning = true; @@ -331,6 +360,7 @@ async function backgroundSync() { await window._reloadAll?.(); showSyncNotification(total); } + drainSyncEvents(relayRes); } catch {} finally { state.bgSyncRunning = false; } diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js index 7c3345e..10bb60c 100644 --- a/public/js/modules/detail.js +++ b/public/js/modules/detail.js @@ -135,7 +135,7 @@ function timeAgo(iso) { // After every full card reload, check whether the card we're viewing is // newer in the fresh state. If so, show the banner. Dismiss it if the // detail view closed in the meantime. -window.hyprcrd?.hooks?.on?.('cards.reloaded', () => { +function checkStaleViewingCard() { if (!detail.classList.contains('open') || !state.viewingCard) { dismissStaleBanner(); return; @@ -144,10 +144,19 @@ window.hyprcrd?.hooks?.on?.('cards.reloaded', () => { const fresh = state.cards.find(c => c.id == viewingId); if (!fresh) return; // card deleted — separate UX, skip banner const old = state.viewingCard; - if ((fresh.updated_at || '') > (old.updated_at || '')) { - showStaleBanner(fresh); - } -}); + // Compare updated_at OR any field that might have changed. The detail + // view captures viewingCard by reference on open, so a replaced + // state.cards array (via _reloadAll) gives us a different object — if + // any visible field differs, surface the banner. + const changed = + (fresh.updated_at || '') > (old.updated_at || '') || + (fresh.title || '') !== (old.title || '') || + (fresh.body || '') !== (old.body || '') || + (fresh.color || '') !== (old.color || '') || + (fresh.tags || '') !== (old.tags || ''); + if (changed) showStaleBanner(fresh); +} +window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard); function clearMorphGhost() { if (morphTimer) { diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index 14c933a..6a6e051 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -588,6 +588,33 @@ function collabSyncCards(PDO $db): array { $fields = collabDecryptPayload($c['payload'] ?? ''); if ($fields === null) continue; + // Fetch referenced attachments BEFORE applying the body so that + // the moment state.cards reflects the new body (via _reloadAll), + // every `uploads/xxx.png` reference is already on disk. Without + // this, a user who opens the card preview between apply and + // fetch would get a broken-image render — and the browser's + // 404 response can stick around in the page's image cache even + // after the file finally lands. + $pulledBody = $fields['body'] ?? ''; + if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) { + $uploadDir = UPLOAD_DIR; + if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true); + foreach (array_unique($fm[1]) as $fn) { + $dest = $uploadDir . '/' . $fn; + if (file_exists($dest)) continue; + $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn)); + if (!($attRes['ok'] ?? false)) continue; + foreach (($attRes['attachments'] ?? []) as $a) { + if (($a['filename'] ?? '') !== $fn) continue; + $decoded = collabDecryptPayload($a['data'] ?? ''); + if ($decoded && isset($decoded['raw'])) { + $bytes = base64_decode($decoded['raw']); + if ($bytes !== false) file_put_contents($dest, $bytes); + } + } + } + } + if ($local) { // LWW: only accept remote if it's strictly newer than local if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue; @@ -675,38 +702,17 @@ function collabSyncCards(PDO $db): array { $stats['pulled']++; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; - // Notify the card's author that we synced it (best-effort; bot - // chat message, rendered in orange on the author's side). Only - // fires for cards authored by someone else. + // Notify the card's author that we synced it (best-effort sync + // event → toast on the author's side). Only fires for cards + // authored by someone else. if ($foreignAuthor && !empty($c['author_username'])) { $cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)'; - collabRelayCall('/api/chat/bot', 'POST', [ + collabRelayCall('/api/sync-events', 'POST', [ 'recipient' => $c['author_username'], 'body' => $selfUser . ' synced: ' . $cardTitle, ]); } - // Fetch missing attachments referenced in the body. - $pulledBody = $fields['body'] ?? ''; - if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) { - $uploadDir = UPLOAD_DIR; - if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true); - foreach (array_unique($fm[1]) as $fn) { - $dest = $uploadDir . '/' . $fn; - if (file_exists($dest)) continue; // already have it - $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn)); - if (!($attRes['ok'] ?? false)) continue; - $atts = $attRes['attachments'] ?? []; - foreach ($atts as $a) { - if (($a['filename'] ?? '') !== $fn) continue; - $decoded = collabDecryptPayload($a['data'] ?? ''); - if ($decoded && isset($decoded['raw'])) { - $bytes = base64_decode($decoded['raw']); - if ($bytes !== false) file_put_contents($dest, $bytes); - } - } - } - } } } finally { // Bump `collab_buckets.updated_at` for every bucket that received diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index 180597f..ee87ec2 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -189,14 +189,31 @@ $chatColNames = array_column($chatCols, 'name'); if (!in_array('seen', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); } -// Bot notifications: automated sync confirmations ("alice synced: My Card"). -// `bot` column = 1 means the UI renders it as a system notification in the -// orange accent color instead of a regular chat bubble. -if (!in_array('bot', $chatColNames)) { - $db->exec('ALTER TABLE chat_messages ADD COLUMN bot INTEGER NOT NULL DEFAULT 0'); +// Sync-notification cleanup: the bot=1 chat messages turned out to clutter +// the chat thread. The feature moved to its own `relay_sync_events` queue +// (see below). Drop any leftover bot rows so they stop showing up in +// users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs +// a full table rebuild — not worth it for an ignored nullable column). +if (in_array('bot', $chatColNames)) { + $db->exec('DELETE FROM chat_messages WHERE bot = 1'); } $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); +// Sync-event notifications: replaces the old bot-chat mechanism. When a +// client syncs a card they received, they POST here; the sender polls +// GET /api/sync-events and surfaces each as a transient toast. Rows are +// short-lived — we drop fetched ones after an hour, unfetched after a day. +$db->exec('CREATE TABLE IF NOT EXISTS relay_sync_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_id INTEGER NOT NULL, + sender_username TEXT NOT NULL, + body TEXT NOT NULL, + fetched INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_sync_events_recipient ON relay_sync_events(recipient_id, id)'); + // Collaborative buckets: shared among multiple tenants. Each bucket has one // owner and a membership set. Buckets persist (not ephemeral) since they are // the container for ongoing collaboration — cleanup only when deleted. @@ -300,6 +317,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')" $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); // Chat: 7 days, longer than card sync but still ephemeral $db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); +// Sync events are transient toasts — drop fetched ones after an hour, +// everything after 24h regardless (sender was offline too long to care). +$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')"); +$db->exec("DELETE FROM relay_sync_events WHERE created_at < datetime('now', '-24 hours')"); // Collab-card tombstones: 7 days so late pollers still see the delete. $db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')"); // Collab-card locks: drop everything past its TTL on every request. diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php index 903a094..56a1be1 100644 --- a/relay/inc/routes/chat.php +++ b/relay/inc/routes/chat.php @@ -62,7 +62,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404); $peerId = $peerTenant['id']; $stmt = $db->prepare(' - SELECT id, sender_id, sender_username, body, created_at, seen, bot + SELECT id, sender_id, sender_username, body, created_at, seen FROM chat_messages WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s) ORDER BY created_at ASC LIMIT 500 @@ -78,7 +78,6 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'seen' => (int)$row['seen'], - 'bot' => (int)($row['bot'] ?? 0), ]; } // Mark incoming as seen @@ -107,28 +106,53 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) { $clientTenant = $clientTenant ?? authClient($db); -// Bot notification: client announces "I synced a card" to the sender. -// No encryption — body is a short system-generated string like -// "alice synced: Card Title". bot=1 flags it so the UI renders in -// the orange accent color. -if ($route === '/api/chat/bot' && $method === 'POST') { +// Sync-event notifications. Client API only: a tenant that just synced +// a received card POSTs here to announce it to the original sender, and +// the sender polls GET to surface each event as a transient toast. +// Bodies are short plaintext strings like "alice synced: Card Title" — +// no encryption, no per-peer routing through the chat thread, no +// clutter in the regular chat history. + +if ($route === '/api/sync-events' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); $recipientName = trim((string)($input['recipient'] ?? '')); if ($body === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); - if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // no self-notifications + if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); - $interKey = getInterTenantKey($db, $MASTER_KEY); - if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); - $stored = clientEncrypt($body, $interKey); - $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, bot) VALUES (:r, :s, :sn, :b, 1)') - ->execute(['r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored]); + $db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)') + ->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } +if ($route === '/api/sync-events' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $stmt = $db->prepare(' + SELECT id, sender_username, body, created_at + FROM relay_sync_events + WHERE recipient_id = :r AND fetched = 0 + ORDER BY id ASC + LIMIT 100 + '); + $stmt->execute(['r' => $clientTenant['id']]); + $rows = $stmt->fetchAll(); + if ($rows) { + $maxId = (int)$rows[count($rows) - 1]['id']; + $db->prepare('UPDATE relay_sync_events SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') + ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); + } + $out = array_map(fn($r) => [ + 'id' => (int)$r['id'], + 'sender_username' => $r['sender_username'], + 'body' => $r['body'], + 'created_at' => $r['created_at'], + ], $rows); + jsonResponse(['events' => $out]); +} + if ($route === '/api/chat' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); diff --git a/relay/ui/app.js b/relay/ui/app.js index 2a1e5f4..0754255 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -719,20 +719,12 @@ async function loadChatHistory(peer, quiet = false) { return; } - container.innerHTML = messages.map(m => { - if (m.bot) { - return ` -
-
${escapeHtml(m.body)}
-
${formatChatTime(m.created_at)}
-
`; - } - return ` + container.innerHTML = messages.map(m => `
${escapeHtml(m.body)}
${formatChatTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
-
`; - }).join(''); + + `).join(''); if (wasAtBottom || !quiet) { container.scrollTop = container.scrollHeight; diff --git a/relay/ui/style.css b/relay/ui/style.css index 546bb0e..148c02f 100644 --- a/relay/ui/style.css +++ b/relay/ui/style.css @@ -280,13 +280,6 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point } .chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; } .chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; } -.chat-msg.bot { - align-self: center; max-width: 90%; - background: rgba(251, 146, 60, 0.12); color: #fb923c; - border: 1px solid rgba(251, 146, 60, 0.35); - font-size: 11px; text-align: center; -} -.chat-msg.bot .msg-time { text-align: center; } .chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; } .chat-msg.mine .msg-time { text-align: right; } .chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }