From 26f95ba0790edd307c461da76fe29e3b336318c1 Mon Sep 17 00:00:00 2001 From: th Date: Tue, 14 Apr 2026 22:39:32 +0200 Subject: [PATCH] collab per-card unread, detail stale banner, image sync race fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - per-card unread dot on board (collab-buckets): backend tracks last_seen_at per card, poll exposes unread_cards, frontend decorates matching board cards; cleared on any open path via detail.opened hook - detail.opened + cards.reloaded hooks on the hyprcrd event bus - stale banner in detail view with manual Reload when the currently viewed card got updated by a peer — no content swap under the user - collab image sync: push attachments BEFORE card (race fix) + pull-side self-heal scans every collab card for missing uploads each poll - relay user deletion now purges chat_messages sent by deleted user so ghost threads stop appearing for their peers - calendar header nav buttons styled as square bordered buttons that honor --ui-radius-button; title bar widened to 280px so font variants don't wrap - .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- electron/package.json | 2 +- public/css/style.css | 32 ++++- public/index.php | 8 +- public/js/app.js | 11 +- public/js/modules/detail.js | 75 ++++++++++ public/plugins/collab-buckets/backend.php | 135 ++++++++++++++---- .../plugins/collab-buckets/collab-buckets.css | 18 +++ .../plugins/collab-buckets/collab-buckets.js | 71 ++++++++- relay/inc/routes/admin.php | 5 + 10 files changed, 324 insertions(+), 35 deletions(-) diff --git a/config.php b/config.php index 1c7befb..44d8609 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ Today -
- -

- +
+ +

+
+ + `; + host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling); + staleBannerEl = bar; + bar.querySelector('.detail-stale-reload').addEventListener('click', () => { + const current = state.cards.find(c => c.id == state.viewingCard?.id); + if (current) { + state.viewingCard = current; + renderSidebar(current); + showContent(current); + } + dismissStaleBanner(); + }); + bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner); +} + +function timeAgo(iso) { + const t = new Date(iso).getTime(); + if (!isFinite(t)) return ''; + const s = Math.max(0, (Date.now() - t) / 1000); + if (s < 60) return `${Math.floor(s)}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +// 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', () => { + if (!detail.classList.contains('open') || !state.viewingCard) { + dismissStaleBanner(); + return; + } + const viewingId = state.viewingCard.id; + 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); + } +}); + function clearMorphGhost() { if (morphTimer) { clearTimeout(morphTimer); @@ -370,6 +444,7 @@ export function closeDetail(options = {}) { closeImageFullscreen(); detail.classList.remove('open', 'zen'); + dismissStaleBanner(); removeZenHandles(); document.documentElement.classList.remove('detail-zen-open'); document.body.classList.remove('detail-zen-open'); diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index fd1b2a3..14c933a 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -49,10 +49,20 @@ bucket_uid TEXT NOT NULL, last_push_at TEXT, last_remote_at TEXT, + last_seen_at TEXT, FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE ) "); $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)"); + // Per-card unread tracking (added 2026-04-14). Existing deployments need + // the column added explicitly. + $ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name'); + if (!in_array('last_seen_at', $ccsCols)) { + $db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT"); + // Backfill: everything existing gets seen=now so we don't flood the + // user with a board full of red dots after they deploy this. + $db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')"); + } // Tombstone outbox: populated by a BEFORE DELETE trigger on the cards // table. The poll's push phase drains it and sends each entry as a @@ -449,12 +459,13 @@ function collabSyncCards(PDO $db): array { $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id'); $stateUpsert = $db->prepare(" - INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) - VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL)) + INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at) + VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2) ON CONFLICT(card_id) DO UPDATE SET relay_uid = excluded.relay_uid, bucket_uid = excluded.bucket_uid, - last_push_at = excluded.last_push_at + last_push_at = excluded.last_push_at, + last_seen_at = excluded.last_seen_at "); foreach ($dirty as $card) { @@ -467,6 +478,31 @@ function collabSyncCards(PDO $db): array { $payload = collabEncryptPayload(collabBuildCardPayload($card)); if ($payload === null) continue; // no relay_key or openssl failure + // Attachments FIRST, card second. If a peer polls between the two + // HTTP calls, they'd see the new card body with an `uploads/xyz.png` + // reference but the attachment wouldn't be on the relay yet — their + // fetch returns empty and the image renders broken permanently + // (the pull short-circuits on the card's next poll because + // updated_at doesn't change). Pushing attachments first guarantees + // the relay's state is consistent: if the card is visible, its + // images are fetchable. + $body = $card['body'] ?? ''; + if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) { + foreach (array_unique($fm[1]) as $fn) { + $localPath = UPLOAD_DIR . '/' . $fn; + if (!is_file($localPath)) continue; + $bytes = file_get_contents($localPath); + if ($bytes === false) continue; + $encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]); + if ($encoded === null) continue; + collabRelayCall('/api/collab-attachments', 'POST', [ + 'bucket_uid' => $card['bucket_uid'], + 'filename' => $fn, + 'data' => $encoded, + ]); + } + } + $res = collabRelayCall('/api/collab-cards', 'POST', [ 'bucket_uid' => $card['bucket_uid'], 'card_uid' => $relayUid, @@ -491,27 +527,9 @@ function collabSyncCards(PDO $db): array { 'ru' => $relayUid, 'bu' => $card['bucket_uid'], 'push' => $res['updated_at'] ?? $card['updated_at'], + 'push2'=> $res['updated_at'] ?? $card['updated_at'], ]); $stats['pushed']++; - - // Push referenced local attachments to the bucket (encrypted, - // member-scoped). Extract `uploads/.` from the body. - $body = $card['body'] ?? ''; - if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) { - foreach (array_unique($fm[1]) as $fn) { - $localPath = UPLOAD_DIR . '/' . $fn; - if (!is_file($localPath)) continue; - $bytes = file_get_contents($localPath); - if ($bytes === false) continue; - $encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]); - if ($encoded === null) continue; - collabRelayCall('/api/collab-attachments', 'POST', [ - 'bucket_uid' => $card['bucket_uid'], - 'filename' => $fn, - 'data' => $encoded, - ]); - } - } } // ── 3. Pull deltas ───────────────────────────────────── @@ -629,21 +647,30 @@ function collabSyncCards(PDO $db): array { $localId = (int)$db->lastInsertId(); } - // Record the remote state so we don't push it back on the next poll + // Record the remote state so we don't push it back on the next + // poll. last_seen_at semantics: self-authored updates (our own + // echo) mark as seen; foreign-authored updates leave last_seen_at + // untouched so the red dot fires on the board. + $seenAt = $foreignAuthor ? null : $c['updated_at']; $db->prepare(" - INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) - VALUES (:cid, :ru, :bu, :p, :r) + INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at) + VALUES (:cid, :ru, :bu, :p, :r, :s) ON CONFLICT(card_id) DO UPDATE SET relay_uid = excluded.relay_uid, bucket_uid = excluded.bucket_uid, last_push_at = excluded.last_push_at, - last_remote_at = excluded.last_remote_at + last_remote_at = excluded.last_remote_at, + last_seen_at = CASE + WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at + ELSE collab_card_state.last_seen_at + END ")->execute([ 'cid' => $localId, 'ru' => $c['card_uid'], 'bu' => $c['bucket_uid'], 'p' => $c['updated_at'], 'r' => $c['updated_at'], + 's' => $seenAt, ]); $stats['pulled']++; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; @@ -701,6 +728,40 @@ function collabSyncCards(PDO $db): array { } if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); + + // Self-heal phase: walk every card in a collab bucket and try to fetch + // any `uploads/xxx.png` reference whose file isn't on disk. Covers the + // historical race where a card body arrived before its attachment, as + // well as any one-off fetch failure (network blip, timeout) that left + // a card with a broken image reference. Normal-case cost is trivial: + // one SQL, one `file_exists()` per reference, no HTTP when everything + // is already in place. + $missing = $db->query(" + SELECT c.id, c.body, b.collab_uid AS bucket_uid + FROM cards c + INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL + WHERE c.body LIKE '%uploads/%' + ")->fetchAll(); + $uploadDir = UPLOAD_DIR; + if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true); + foreach ($missing as $mc) { + if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue; + foreach (array_unique($fm[1]) as $fn) { + $dest = $uploadDir . '/' . $fn; + if (file_exists($dest)) continue; + $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['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); + } + } + } + } + return $stats; } @@ -897,6 +958,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { // Any pending outbox entries still waiting for a reachable relay $outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn(); + // Per-card unread set: cards whose local updated_at is newer than + // their last_seen_at (or never seen). Frontend uses this to draw + // the little red dot on the board card. + $unreadCards = array_map('intval', $db->query(" + SELECT cs.card_id + FROM collab_card_state cs + JOIN cards c ON c.id = cs.card_id + WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at + ")->fetchAll(PDO::FETCH_COLUMN)); + collabJson([ 'ok' => true, 'self' => collabRelaySettings()['user'], @@ -907,6 +978,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { 'locks' => $locks, 'outbox_processed' => $outboxProcessed, 'outbox_pending' => $outboxPending, + 'unread_cards' => $unreadCards, ]); } @@ -921,6 +993,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { collabJson(['ok' => true]); } + // ── Mark a single collab card as seen (clears its red dot) ──── + if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $cardId = (int)($input['card_id'] ?? 0); + if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400); + Database::connect() + ->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c") + ->execute(['c' => $cardId]); + collabJson(['ok' => true]); + } + // ── Proxy: relay tenants (for the create-bucket member picker) ── // The relay's /api/tenants returns {tenants, self} without an `ok` field, // so we wrap it here to match the shape the frontend expects. diff --git a/public/plugins/collab-buckets/collab-buckets.css b/public/plugins/collab-buckets/collab-buckets.css index 4dc2cb9..247f1bf 100644 --- a/public/plugins/collab-buckets/collab-buckets.css +++ b/public/plugins/collab-buckets/collab-buckets.css @@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing { .collab-conflict-toast.collab-lock-toast b { color: var(--t-accent2); } + +/* ── Per-card unread indicator ── + Mirror of the bucket-tab red dot: a card with a foreign edit we haven't + opened yet gets a small glowing dot pinned to the top-right corner. */ +.board-card.collab-card-unread { position: relative; } +.board-card.collab-card-unread .collab-card-dot { + position: absolute; + top: -3px; + right: -3px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--t-danger); + border: 1px solid var(--t-bg); + box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent); + pointer-events: none; + z-index: 3; +} diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js index 283533a..55a6b82 100644 --- a/public/plugins/collab-buckets/collab-buckets.js +++ b/public/plugins/collab-buckets/collab-buckets.js @@ -24,6 +24,8 @@ const $ = (id) => document.getElementById(id); // uid -> collab bucket meta (from /api/collab-buckets) let collabByUid = new Map(); +// card_id (number) -> true for cards with foreign edits we haven't opened yet +let unreadCards = new Set(); let pollHandle = null; const POLL_INTERVAL_MS = 30000; @@ -92,6 +94,12 @@ async function pollOnce() { const n = res.outbox_processed; showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`); } + // Per-card unread list: cards with foreign edits we haven't opened + // yet. Decorated on the board via decorateBoardUnread(). + if (Array.isArray(res.unread_cards)) { + unreadCards = new Set(res.unread_cards.map(Number)); + decorateBoardUnread(); + } // Phase 4: edit-lock list comes back with the poll if (Array.isArray(res.locks)) { const next = new Map(); @@ -638,12 +646,71 @@ function decorateBoardLocks() { } } +/** + * Walk every rendered board card and add / remove the "red dot" unread + * indicator for cards with foreign edits we haven't opened yet. + * Idempotent — safe to re-run on every board re-render. + */ +function decorateBoardUnread() { + const board = document.getElementById('board'); + if (!board) return; + for (const el of board.querySelectorAll('.board-card[data-id]')) { + const cid = parseInt(el.dataset.id); + const isUnread = unreadCards.has(cid); + el.classList.toggle('collab-card-unread', isUnread); + let dot = el.querySelector('.collab-card-dot'); + if (isUnread && !dot) { + dot = document.createElement('span'); + dot.className = 'collab-card-dot'; + el.appendChild(dot); + } else if (!isUnread && dot) { + dot.remove(); + } + } +} + +// Clear a card's unread state locally + on the backend. Called from +// - board click (piggy-backing on the natural open-card interaction) +// - detail.opened hook (when the detail view opens via any path: +// keyboard shortcut, related-card link, backlink, search result, etc.) +function clearCardUnread(cid) { + cid = parseInt(cid); + if (!cid || !unreadCards.has(cid)) return; + unreadCards.delete(cid); + const el = document.querySelector(`#board .board-card[data-id="${cid}"]`); + if (el) { + el.classList.remove('collab-card-unread'); + el.querySelector('.collab-card-dot')?.remove(); + } + hyprcrd.api('api/collab-buckets/mark-card-seen', { + method: 'POST', body: { card_id: cid }, + }).catch(() => {}); +} + +function setupCardClickInterceptor() { + const board = document.getElementById('board'); + if (board) { + board.addEventListener('click', (e) => { + const el = e.target.closest('.board-card[data-id]'); + if (el) clearCardUnread(el.dataset.id); + }); + } + // Detail view has its own open path (keyboard shortcuts, backlinks, + // related-card navigation) that doesn't route through a board-card + // click. Listen for the explicit detail.opened hook so every open + // method clears the dot. + hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id)); +} + // Re-decorate whenever the board re-renders (reuses the existing #board // MutationObserver pattern). function setupBoardObserver() { const board = document.getElementById('board'); if (!board) return; - const obs = new MutationObserver(() => decorateBoardLocks()); + const obs = new MutationObserver(() => { + decorateBoardLocks(); + decorateBoardUnread(); + }); obs.observe(board, { childList: true, subtree: false }); } @@ -899,12 +966,14 @@ async function init() { } setupBarObserver(); setupClickInterceptor(); + setupCardClickInterceptor(); setupBoardObserver(); await loadCollabMeta(); await pollOnce(); await loadCollabMeta(); decorateBucketBar(); decorateBoardLocks(); + decorateBoardUnread(); startPolling(); } diff --git a/relay/inc/routes/admin.php b/relay/inc/routes/admin.php index 48573c5..ddfe6e2 100644 --- a/relay/inc/routes/admin.php +++ b/relay/inc/routes/admin.php @@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') { if ($id === (int)$sessionTenant['id']) { jsonResponse(['error' => 'cannot delete yourself'], 400); } + // chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE + // SET NULL — that leaves sender_username populated so the deleted user + // keeps showing up as a chat thread for their peers. Purge those rows + // explicitly BEFORE the tenant delete, while sender_id is still matchable. + $db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]); $db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]); jsonResponse(['ok' => true]); }