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
+26
-5
@@ -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.
|
||||
|
||||
+37
-13
@@ -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);
|
||||
|
||||
+3
-11
@@ -719,20 +719,12 @@ async function loadChatHistory(peer, quiet = false) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = messages.map(m => {
|
||||
if (m.bot) {
|
||||
return `
|
||||
<div class="chat-msg bot">
|
||||
<div><i class="ph ph-robot"></i> ${escapeHtml(m.body)}</div>
|
||||
<div class="msg-time">${formatChatTime(m.created_at)}</div>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
container.innerHTML = messages.map(m => `
|
||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||
<div>${escapeHtml(m.body)}</div>
|
||||
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
if (wasAtBottom || !quiet) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user