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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.7.0');
|
define('APP_VERSION', '1.8.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+27
-7
@@ -892,17 +892,17 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
$pulled++;
|
$pulled++;
|
||||||
|
|
||||||
// Send bot notification back to the sender so their chat shows the
|
// Fire a sync-event notification to the sender so their hyprcrd
|
||||||
// sync confirmation. Best effort — relay may be older and not have
|
// surfaces a toast. Best-effort — an older relay may not have
|
||||||
// /api/chat/bot; we don't want to fail the poll on it.
|
// /api/sync-events, and we don't want to fail the whole poll.
|
||||||
$senderName = $msg['sender_username'] ?? '';
|
$senderName = $msg['sender_username'] ?? '';
|
||||||
if ($senderName && $senderName !== $relayUser) {
|
if ($senderName && $senderName !== $relayUser) {
|
||||||
$botBody = $relayUser . ' synced: ' . $title;
|
$eventBody = $relayUser . ' synced: ' . $title;
|
||||||
$bch = curl_init(rtrim($relayUrl, '/') . '/api/chat/bot');
|
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
|
||||||
curl_setopt_array($bch, [
|
curl_setopt_array($bch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $botBody]),
|
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]),
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'X-Relay-User: ' . $relayUser,
|
'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)
|
// List relay tenants (for the recipient picker)
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export async function triggerSync() {
|
|||||||
const relayRes = await fetch('api/relay/poll', {
|
const relayRes = await fetch('api/relay/poll', {
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const s = res.stats || {};
|
const s = res.stats || {};
|
||||||
@@ -265,6 +266,7 @@ $('relay-test')?.addEventListener('click', async () => {
|
|||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
||||||
if (res.pulled) await window._reloadAll?.();
|
if (res.pulled) await window._reloadAll?.();
|
||||||
|
drainSyncEvents(res);
|
||||||
} else {
|
} else {
|
||||||
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
||||||
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
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);
|
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() {
|
async function backgroundSync() {
|
||||||
if (state.bgSyncRunning) return;
|
if (state.bgSyncRunning) return;
|
||||||
state.bgSyncRunning = true;
|
state.bgSyncRunning = true;
|
||||||
@@ -331,6 +360,7 @@ async function backgroundSync() {
|
|||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
showSyncNotification(total);
|
showSyncNotification(total);
|
||||||
}
|
}
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
} catch {} finally {
|
} catch {} finally {
|
||||||
state.bgSyncRunning = false;
|
state.bgSyncRunning = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ function timeAgo(iso) {
|
|||||||
// After every full card reload, check whether the card we're viewing is
|
// 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
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||||
// detail view closed in the meantime.
|
// detail view closed in the meantime.
|
||||||
window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
|
function checkStaleViewingCard() {
|
||||||
if (!detail.classList.contains('open') || !state.viewingCard) {
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||||
dismissStaleBanner();
|
dismissStaleBanner();
|
||||||
return;
|
return;
|
||||||
@@ -144,10 +144,19 @@ window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
|
|||||||
const fresh = state.cards.find(c => c.id == viewingId);
|
const fresh = state.cards.find(c => c.id == viewingId);
|
||||||
if (!fresh) return; // card deleted — separate UX, skip banner
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||||
const old = state.viewingCard;
|
const old = state.viewingCard;
|
||||||
if ((fresh.updated_at || '') > (old.updated_at || '')) {
|
// Compare updated_at OR any field that might have changed. The detail
|
||||||
showStaleBanner(fresh);
|
// 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() {
|
function clearMorphGhost() {
|
||||||
if (morphTimer) {
|
if (morphTimer) {
|
||||||
|
|||||||
@@ -588,6 +588,33 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$fields = collabDecryptPayload($c['payload'] ?? '');
|
$fields = collabDecryptPayload($c['payload'] ?? '');
|
||||||
if ($fields === null) continue;
|
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) {
|
if ($local) {
|
||||||
// LWW: only accept remote if it's strictly newer than local
|
// LWW: only accept remote if it's strictly newer than local
|
||||||
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
|
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
|
||||||
@@ -675,38 +702,17 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$stats['pulled']++;
|
$stats['pulled']++;
|
||||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
|
|
||||||
// Notify the card's author that we synced it (best-effort; bot
|
// Notify the card's author that we synced it (best-effort sync
|
||||||
// chat message, rendered in orange on the author's side). Only
|
// event → toast on the author's side). Only fires for cards
|
||||||
// fires for cards authored by someone else.
|
// authored by someone else.
|
||||||
if ($foreignAuthor && !empty($c['author_username'])) {
|
if ($foreignAuthor && !empty($c['author_username'])) {
|
||||||
$cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)';
|
$cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)';
|
||||||
collabRelayCall('/api/chat/bot', 'POST', [
|
collabRelayCall('/api/sync-events', 'POST', [
|
||||||
'recipient' => $c['author_username'],
|
'recipient' => $c['author_username'],
|
||||||
'body' => $selfUser . ' synced: ' . $cardTitle,
|
'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 {
|
} finally {
|
||||||
// Bump `collab_buckets.updated_at` for every bucket that received
|
// Bump `collab_buckets.updated_at` for every bucket that received
|
||||||
|
|||||||
+26
-5
@@ -189,14 +189,31 @@ $chatColNames = array_column($chatCols, 'name');
|
|||||||
if (!in_array('seen', $chatColNames)) {
|
if (!in_array('seen', $chatColNames)) {
|
||||||
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
// Bot notifications: automated sync confirmations ("alice synced: My Card").
|
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
|
||||||
// `bot` column = 1 means the UI renders it as a system notification in the
|
// the chat thread. The feature moved to its own `relay_sync_events` queue
|
||||||
// orange accent color instead of a regular chat bubble.
|
// (see below). Drop any leftover bot rows so they stop showing up in
|
||||||
if (!in_array('bot', $chatColNames)) {
|
// users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs
|
||||||
$db->exec('ALTER TABLE chat_messages ADD COLUMN bot INTEGER NOT NULL DEFAULT 0');
|
// 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)');
|
$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
|
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
||||||
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
||||||
// the container for ongoing collaboration — cleanup only when deleted.
|
// 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')");
|
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
||||||
// Chat: 7 days, longer than card sync but still ephemeral
|
// Chat: 7 days, longer than card sync but still ephemeral
|
||||||
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
$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.
|
// 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')");
|
$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.
|
// 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);
|
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
|
||||||
$peerId = $peerTenant['id'];
|
$peerId = $peerTenant['id'];
|
||||||
$stmt = $db->prepare('
|
$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
|
FROM chat_messages
|
||||||
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
||||||
ORDER BY created_at ASC LIMIT 500
|
ORDER BY created_at ASC LIMIT 500
|
||||||
@@ -78,7 +78,6 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
|||||||
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
'created_at' => $row['created_at'],
|
'created_at' => $row['created_at'],
|
||||||
'seen' => (int)$row['seen'],
|
'seen' => (int)$row['seen'],
|
||||||
'bot' => (int)($row['bot'] ?? 0),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Mark incoming as seen
|
// Mark incoming as seen
|
||||||
@@ -107,28 +106,53 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
|||||||
|
|
||||||
$clientTenant = $clientTenant ?? authClient($db);
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
// Bot notification: client announces "I synced a card" to the sender.
|
// Sync-event notifications. Client API only: a tenant that just synced
|
||||||
// No encryption — body is a short system-generated string like
|
// a received card POSTs here to announce it to the original sender, and
|
||||||
// "alice synced: Card Title". bot=1 flags it so the UI renders in
|
// the sender polls GET to surface each event as a transient toast.
|
||||||
// the orange accent color.
|
// Bodies are short plaintext strings like "alice synced: Card Title" —
|
||||||
if ($route === '/api/chat/bot' && $method === 'POST') {
|
// 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);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$body = trim((string)($input['body'] ?? ''));
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient 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);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
|
||||||
$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]);
|
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
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 ($route === '/api/chat' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
|||||||
+3
-11
@@ -719,20 +719,12 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = messages.map(m => {
|
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 `
|
|
||||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||||
<div>${escapeHtml(m.body)}</div>
|
<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 class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||||
</div>`;
|
</div>
|
||||||
}).join('');
|
`).join('');
|
||||||
|
|
||||||
if (wasAtBottom || !quiet) {
|
if (wasAtBottom || !quiet) {
|
||||||
container.scrollTop = container.scrollHeight;
|
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.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.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 .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
|
||||||
.chat-msg.mine .msg-time { text-align: right; }
|
.chat-msg.mine .msg-time { text-align: right; }
|
||||||
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
|||||||
Reference in new issue
Block a user