collab per-card unread, detail stale banner, image sync race fix

- 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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-14 22:39:32 +02:00
1 parent 39e94b8015
commit 26f95ba079
10 files changed
+324 -35

No files matched your search

@@ -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();
}