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

+9 -5
View File
@@ -1,7 +1,7 @@
// ── Bucket management ──
import { $, esc, api } from './util.js';
import { state, CARD_COLORS } from './state.js';
import { state, CARD_COLORS, setActiveBucket, ensureActiveBucketValid } from './state.js';
import { showDialog } from './dialog.js';
import { pushUndo } from './undo.js';
@@ -123,7 +123,7 @@ async function showBucketCtx(x, y, bucket) {
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
if (!ok) return;
}
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
if (state.activeBucket == bucket.id) setActiveBucket('all');
// Collab bucket: route through plugin so the relay is notified
// (owner = delete for everyone; member = just leave).
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
@@ -155,7 +155,7 @@ async function showBucketCtx(x, y, bucket) {
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
if (!ok) return;
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
if (state.activeBucket == bucket.id) setActiveBucket('all');
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
const res = await window._collabBucketsDelete(bucket.collab_uid);
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
@@ -188,6 +188,10 @@ const tasksFilter = $('tasks-bucket-filter');
export async function loadBuckets() {
state.buckets = await api('api/buckets');
// The persisted activeBucket may point to a bucket that no longer
// exists (deleted in another tab, removed via import, etc.). Reset
// to 'all' in that case before rendering.
ensureActiveBucketValid();
renderBar();
populateSelects();
}
@@ -366,7 +370,7 @@ function renderBar() {
pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => {
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) {