chat: read receipts (delivered + seen) and persisted active bucket

Read receipts on chat messages — single check for delivered (recipient
polled), double check for seen (recipient opened the thread). Pending
state shows a clock until the relay acks the post.

Relay (relay/):
* bootstrap.php: ALTER TABLE chat_messages ADD COLUMN seen + a
  (sender_id, id) index for the sender-side state poll. Migration is
  idempotent via PRAGMA table_info.
* routes/chat.php: two new routes
  - GET  /api/chat/sent?since=N — returns the receipt state (id,
    recipient_id, fetched, seen, created_at) for messages the caller
    sent. No body — sender already has the plaintext locally.
  - POST /api/chat/seen { peer } — marks every message FROM peer TO
    caller as seen=1. Idempotent. Frontend calls this when opening
    a thread; sender's next /sent poll picks the new state up.

Plugin backend (public/plugins/chat/backend.php):
* schema migration: chat_messages gets `delivered` and `seen` columns
  (idempotent), only meaningful for direction='out'.
* /api/chat/send: stores the relay's autoincrement id as `relay_id`
  on the local outgoing row so the receipt poll can match against it.
* /api/chat/poll: after the normal incoming pull, also calls
  GET /api/chat/sent?since=last_sent_relay_id and updates local
  outgoing rows with delivered/seen via UPDATE WHERE relay_id. The
  cursor (last_sent_relay_id state value) only advances past rows
  whose state is final (seen=1); rows still in flight stay re-pollable.
  Response now carries `receipts: <count>` so the frontend can refresh
  the open thread when state changes without new incoming.
* /api/chat/read: after the local is_read=1 update, fires a POST to
  the relay's /api/chat/seen so the sender gets the double-check.
  Silent on failure — the user already saw the messages locally.
* /api/chat/messages: SELECT now includes delivered, seen.

Plugin frontend (public/plugins/chat/chat.js):
* renderThread() appends a receipt icon to outgoing bubbles:
  - seen=1                      → ph-checks (accent, double check)
  - delivered=1, seen=0         → ph-check  (muted, single check)
  - both 0                       → ph-clock  (dim, pending)
  Incoming messages get nothing.
* pollOnce() refreshes the open thread when receipts > 0 even with
  no new incoming, so the icons update in real time while the user
  is looking at the thread.

CSS (public/plugins/chat/chat.css):
* .chat-bubble-time becomes flex with gap so timestamp + receipt
  icon sit on one line.
* .chat-receipt.pending / .delivered / .seen color tiers.

board: persist active bucket across reloads

* state.js: state.activeBucket initializes from
  localStorage.getItem('hyprcrd-active-bucket') || 'all'. New
  setActiveBucket(value) helper writes through to localStorage; new
  ensureActiveBucketValid() helper falls back to 'all' if the
  persisted id no longer exists in state.buckets.
* buckets.js: loadBuckets() calls ensureActiveBucketValid() right
  after the API fetch and before renderBar(). All four production
  assignments (delete-keep, delete-all, popover entry click, regular
  tab click) replaced with setActiveBucket() so the persisted value
  stays in sync. Tests still mutate state.activeBucket directly so
  they don't touch localStorage.

chore: bump version to 0.9.6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-11 22:43:28 +02:00
1 parent 9b4869812f
commit 285e3b4abe
8 files changed
+223 -16

No files matched your search

+28 -2
View File
@@ -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' },