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

+109 -26
View File
@@ -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/<hex>.<ext>` 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.