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 `
${escapeHtml(m.body)}
-
${escapeHtml(formatChatTime(m.sent_at))}
+
${escapeHtml(formatChatTime(m.sent_at))}${receipt}
`; }).join(''); // Scroll to bottom @@ -330,6 +339,7 @@ async function pollOnce() { const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null); if (!res?.ok) return; const newCount = res.new || 0; + const receiptUpdates = res.receipts || 0; if (newCount > 0) { await loadConversations(); // If we're inside a thread for any peer, refresh + auto-read it @@ -340,6 +350,10 @@ async function pollOnce() { } // Blink if the flyout is closed if (!isOpen()) startBlink(); + } else if (receiptUpdates > 0 && isOpen() && currentPeer) { + // No new incoming, but receipts (delivered/seen) changed for + // our outgoing — refresh the thread so the check icons update. + await loadThread(currentPeer); } else if (initialPollDone === false) { // First poll: refresh badges in case we have unread from a previous session await loadConversations(); diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index f29ffe4..070c0ee 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -180,6 +180,16 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_messages ( FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)'); +// Read receipts: `fetched` (already present) is "delivered" — set when the +// recipient polls /api/chat. `seen` is "seen" — set when the recipient +// opens the thread with the sender via POST /api/chat/seen. The sender +// learns both states by polling GET /api/chat/sent. +$chatCols = $db->query('PRAGMA table_info(chat_messages)')->fetchAll(); +$chatColNames = array_column($chatCols, 'name'); +if (!in_array('seen', $chatColNames)) { + $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); +} +$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); // Collaborative buckets: shared among multiple tenants. Each bucket has one // owner and a membership set. Buckets persist (not ephemeral) since they are diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php index ba742d6..76edd97 100644 --- a/relay/inc/routes/chat.php +++ b/relay/inc/routes/chat.php @@ -1,7 +1,14 @@ N +// POST /api/chat — send a chat message to {recipient} +// GET /api/chat?since=N — pull all messages addressed to me (id > N) +// GET /api/chat/sent?since=N — pull state updates (delivered/seen) for +// *my* sent messages with id > N. The +// sender uses this to learn read receipts +// without needing a backchannel. +// POST /api/chat/seen {peer} — mark every message FROM peer TO me as +// seen. The sender's next /api/chat/sent +// poll picks the new state up. // // Same decrypt-reencrypt hop as messages.php: arrives encrypted with the // senders client secret, stored at-rest under the inter-tenant key, returned @@ -80,3 +87,46 @@ if ($route === '/api/chat' && $method === 'GET') { } jsonResponse(['messages' => $out, 'max_id' => $maxId]); } + +// ── GET /api/chat/sent ───────────────────────────────────────── +// Returns the receipt state (fetched/seen) for messages the caller has +// sent. Body is intentionally NOT included — the sender already has the +// plaintext locally; the relay only echoes the state flags so the sender +// can render check / double-check on the right messages. + +if ($route === '/api/chat/sent' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $since = (int)($_GET['since'] ?? 0); + $stmt = $db->prepare(' + SELECT id, recipient_id, fetched, seen, created_at + FROM chat_messages + WHERE sender_id = :s AND id > :since + ORDER BY id ASC + LIMIT 1000 + '); + $stmt->execute(['s' => $clientTenant['id'], 'since' => $since]); + $rows = $stmt->fetchAll(); + $maxId = $since; + foreach ($rows as $r) { + if ((int)$r['id'] > $maxId) $maxId = (int)$r['id']; + } + jsonResponse(['receipts' => $rows, 'max_id' => $maxId]); +} + +// ── POST /api/chat/seen ──────────────────────────────────────── +// Body: { peer: "" }. Marks every message that the +// peer sent to the caller as seen=1. Idempotent — safe to call on +// every thread open. + +if ($route === '/api/chat/seen' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $peer = trim((string)($input['peer'] ?? '')); + if ($peer === '') jsonResponse(['error' => 'peer required'], 400); + $db->prepare(' + UPDATE chat_messages + SET seen = 1 + WHERE recipient_id = :r AND sender_username = :p AND seen = 0 + ')->execute(['r' => $clientTenant['id'], 'p' => $peer]); + jsonResponse(['ok' => true]); +}