sync-event toasts, image pull ordering, stale banner field diff
- 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
26f95ba079
commit
1966f8f4c0
10 files changed
+170
-75
No files matched your search
+27
-7
@@ -892,17 +892,17 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
$pulled++;
|
||||
|
||||
// Send bot notification back to the sender so their chat shows the
|
||||
// sync confirmation. Best effort — relay may be older and not have
|
||||
// /api/chat/bot; we don't want to fail the poll on it.
|
||||
// Fire a sync-event notification to the sender so their hyprcrd
|
||||
// surfaces a toast. Best-effort — an older relay may not have
|
||||
// /api/sync-events, and we don't want to fail the whole poll.
|
||||
$senderName = $msg['sender_username'] ?? '';
|
||||
if ($senderName && $senderName !== $relayUser) {
|
||||
$botBody = $relayUser . ' synced: ' . $title;
|
||||
$bch = curl_init(rtrim($relayUrl, '/') . '/api/chat/bot');
|
||||
$eventBody = $relayUser . ' synced: ' . $title;
|
||||
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
|
||||
curl_setopt_array($bch, [
|
||||
CURLOPT_RETURNTRANSFER => 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)
|
||||
|
||||
@@ -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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user