diff --git a/config.php b/config.php index 3f56901..eb0e1be 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { const ebid = entry.dataset.bucket; entry.addEventListener('click', () => { - state.activeBucket = ebid; + setActiveBucket(ebid); state.activeGroup = null; pop.remove(); renderBar(); @@ -420,7 +424,7 @@ function renderBar() { openSuperDropdown(t, parseInt(bid, 10)); }); } else { - t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); }); + t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); }); } if (isUser) { diff --git a/public/js/modules/state.js b/public/js/modules/state.js index faa0318..8ef7ac0 100644 --- a/public/js/modules/state.js +++ b/public/js/modules/state.js @@ -7,8 +7,12 @@ export const state = { cards: [], buckets: [], - // Filters & view - activeBucket: 'all', + // Filters & view — activeBucket is persisted across reloads via + // localStorage. Numeric ids are stringified; the special filters + // ('all', 'current', 'favorites', 'journal', 'none') are stored + // as-is. Validity is re-checked after buckets load — see + // ensureActiveBucketValid() below. + activeBucket: localStorage.getItem('hyprcrd-active-bucket') || 'all', activeTags: [], activeGroup: null, sortMode: localStorage.getItem('hyprcrd-sort') || 'manual', @@ -52,6 +56,28 @@ export const state = { export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag']; export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' }; +const SPECIAL_BUCKETS = new Set(['all', 'current', 'favorites', 'journal', 'none']); + +/** Set state.activeBucket and persist it to localStorage so it survives + * page reloads. Use this instead of `state.activeBucket = …` everywhere + * except tests (tests reset directly to avoid touching localStorage). */ +export function setActiveBucket(value) { + state.activeBucket = value; + if (value == null) localStorage.removeItem('hyprcrd-active-bucket'); + else localStorage.setItem('hyprcrd-active-bucket', String(value)); +} + +/** Called from loadBuckets after the fresh bucket list arrives. If the + * persisted activeBucket points to a bucket id that no longer exists + * (deleted from another tab, removed in import, etc.), fall back to + * 'all'. Special filters are always valid. */ +export function ensureActiveBucketValid() { + const cur = state.activeBucket; + if (!cur || SPECIAL_BUCKETS.has(cur)) return; + const exists = state.buckets.some(b => String(b.id) === String(cur)); + if (!exists) setActiveBucket('all'); +} + export const CARD_COLORS = [ { name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' }, { name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' }, diff --git a/public/plugins/chat/backend.php b/public/plugins/chat/backend.php index a3ea7af..49ced16 100644 --- a/public/plugins/chat/backend.php +++ b/public/plugins/chat/backend.php @@ -32,6 +32,17 @@ "); $db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)"); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL"); + // Read receipts for outgoing messages: `delivered` = recipient polled + // (single check), `seen` = recipient opened the thread (double check). + // Both filled in via /api/chat/sent receipt-poll on the relay side. + $cols = $db->query("PRAGMA table_info(chat_messages)")->fetchAll(); + $colNames = array_column($cols, 'name'); + if (!in_array('delivered', $colNames)) { + $db->exec("ALTER TABLE chat_messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0"); + } + if (!in_array('seen', $colNames)) { + $db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0"); + } // Track the largest relay msg id we've ever seen (so the next poll uses since=that) $db->exec(" CREATE TABLE IF NOT EXISTS chat_state ( @@ -115,7 +126,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { $peer = trim((string)($_GET['peer'] ?? '')); if ($peer === '') chatJson(['error' => 'peer required'], 400); $db = Database::connect(); - $stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500'); + $stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500'); $stmt->execute(['p' => $peer]); chatJson(['messages' => $stmt->fetchAll()]); } @@ -164,10 +175,12 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { $resData = json_decode($response, true); $relayId = isset($resData['id']) ? (int)$resData['id'] : null; - // Store the outgoing message locally so the UI can render it immediately + // Store the outgoing message locally so the UI can render it + // immediately. relay_id is the relay's autoincrement id; the + // receipt-poll matches against it to update delivered/seen state. $db = Database::connect(); - $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "out", :b, NULL, datetime("now"), 1)') - ->execute(['p' => $peer, 'b' => $body]); + $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)') + ->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]); chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); } @@ -218,7 +231,58 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { } if ($maxId > $since) chatSetState('last_relay_id', $maxId); - chatJson(['ok' => true, 'new' => $newCount]); + // ── Receipt poll for our SENT messages ────────────────── + // Pulls fetched/seen state for everything we've sent. Updates + // local outgoing rows by relay_id. Uses an independent `since` + // cursor so we keep getting state changes even on rows we + // already saw before. + $sentSince = (int)chatGetState('last_sent_relay_id', 0); + $ch2 = curl_init(rtrim($r['url'], '/') . '/api/chat/sent?since=' . $sentSince); + curl_setopt_array($ch2, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 10, + ]); + $resp2 = curl_exec($ch2); + $st2 = curl_getinfo($ch2, CURLINFO_HTTP_CODE); + curl_close($ch2); + $receiptUpdates = 0; + if ($st2 === 200) { + $sentData = json_decode($resp2, true) ?: []; + $receipts = $sentData['receipts'] ?? []; + $sentMaxId = (int)($sentData['max_id'] ?? $sentSince); + $upd = $db->prepare('UPDATE chat_messages SET delivered = :d, seen = :s WHERE relay_id = :rid AND direction = "out"'); + foreach ($receipts as $rcpt) { + $upd->execute([ + 'd' => (int)($rcpt['fetched'] ?? 0), + 's' => (int)($rcpt['seen'] ?? 0), + 'rid' => (int)$rcpt['id'], + ]); + $receiptUpdates += $upd->rowCount(); + } + // Cursor only advances when EVERY row's state is final (seen=1). + // Otherwise we need to keep re-polling those rows for updates. + // Cheap implementation: if every row in this batch is seen, bump + // cursor to maxId; else keep polling from the lowest non-seen id. + $allSeen = true; + $minPending = $sentMaxId; + foreach ($receipts as $rcpt) { + if ((int)($rcpt['seen'] ?? 0) !== 1) { + $allSeen = false; + if ((int)$rcpt['id'] - 1 < $minPending) $minPending = (int)$rcpt['id'] - 1; + } + } + if ($allSeen && $sentMaxId > $sentSince) { + chatSetState('last_sent_relay_id', $sentMaxId); + } else if (!$allSeen && $minPending > $sentSince) { + chatSetState('last_sent_relay_id', $minPending); + } + } + + chatJson(['ok' => true, 'new' => $newCount, 'receipts' => $receiptUpdates]); } if ($route === '/api/chat/read' && $method === 'POST') { @@ -228,6 +292,28 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { $db = Database::connect(); $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0') ->execute(['p' => $peer]); + + // Tell the relay this peer's messages have been seen by us so the + // peer's next /api/chat/sent poll picks up the double-check state. + $r = chatRelaySettings(); + if ($r['url'] && $r['user'] && $r['key']) { + $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/seen'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['peer' => $peer]), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 5, + ]); + curl_exec($ch); + curl_close($ch); + // Failures are silent — the user already saw the messages + // locally, the relay state is just lagging. + } chatJson(['ok' => true]); } diff --git a/public/plugins/chat/chat.css b/public/plugins/chat/chat.css index 75f2167..0a302ca 100644 --- a/public/plugins/chat/chat.css +++ b/public/plugins/chat/chat.css @@ -170,6 +170,9 @@ white-space: pre-wrap; line-height: 1.4; border: 1px solid var(--t-border); + /* Pick up the user's card-radius design token so chat bubbles look + consistent with the rest of the surface (sharp / rounded / pill). */ + border-radius: var(--ui-radius-card, 0px); } .chat-bubble.in { @@ -192,11 +195,25 @@ color: var(--t-dim); margin-top: 2px; font-family: var(--t-font-pixel); + display: flex; + align-items: center; + gap: 4px; } .chat-bubble.out + .chat-bubble-time { align-self: flex-end; } .chat-bubble.in + .chat-bubble-time { align-self: flex-start; } +/* Read receipts: clock = sent (relay not yet acked), single check = + delivered (recipient polled), double check = seen (recipient opened + the thread). Single/double share the same icon size as the timestamp. */ +.chat-receipt { + font-size: 11px; + line-height: 1; +} +.chat-receipt.pending { color: var(--t-dim); } +.chat-receipt.delivered { color: var(--t-muted); } +.chat-receipt.seen { color: var(--t-accent); } + #chat-thread-form { display: flex; align-items: center; diff --git a/public/plugins/chat/chat.js b/public/plugins/chat/chat.js index 39428f2..c21a7f5 100644 --- a/public/plugins/chat/chat.js +++ b/public/plugins/chat/chat.js @@ -231,9 +231,18 @@ function renderThread() { if (!wrap) return; wrap.innerHTML = currentMessages.map(m => { const cls = m.direction === 'out' ? 'out' : 'in'; + // Read receipts for outgoing messages: single check = recipient + // polled (delivered), double check = recipient opened the thread + // (seen). Incoming messages get no icon. + let receipt = ''; + if (m.direction === 'out') { + if (Number(m.seen)) receipt = ''; + else if (Number(m.delivered)) receipt = ''; + else receipt = ''; + } return `