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>
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
// ── Chat Plugin Frontend ──
|
|
//
|
|
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
|
// (faster while open, slower while closed). Blinks the footer button
|
|
// when new incoming messages arrive while the flyout is closed.
|
|
|
|
const hyprcrd = window.hyprcrd;
|
|
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
// ── State ─────────────────────────────────────────────────────────
|
|
|
|
let conversations = [];
|
|
let currentPeer = null;
|
|
let currentMessages = [];
|
|
let pollHandle = null;
|
|
let pollIntervalMs = 20000; // closed default
|
|
let recipientCache = null; // { tenants: [...], self: '...' }
|
|
let initialPollDone = false;
|
|
|
|
// Chat polls aggressively because the endpoint is cheap (single indexed
|
|
// SELECT with LIMIT 500). Card sync stays on its own slower interval —
|
|
// CalDAV is the expensive one. Tab-hidden pauses the timer entirely.
|
|
const POLL_OPEN_MS = 8000;
|
|
const POLL_CLOSED_MS = 20000;
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
function escapeHtml(str) {
|
|
return String(str ?? '')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function formatChatTime(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
|
if (isNaN(d.getTime())) return '';
|
|
const now = new Date();
|
|
const sameDay = d.toDateString() === now.toDateString();
|
|
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
const diffDays = (now - d) / 86400000;
|
|
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function isOpen() {
|
|
return !$('chat-flyout')?.classList.contains('hidden');
|
|
}
|
|
|
|
// ── Footer button ─────────────────────────────────────────────────
|
|
|
|
function injectFooterButton() {
|
|
const footer = document.querySelector('footer');
|
|
if (!footer) return;
|
|
if (document.getElementById('chat-footer-btn')) return;
|
|
|
|
// Append next to the help button on the right side of the footer
|
|
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
|
|
if (!rightGroup) return;
|
|
|
|
const btn = document.createElement('button');
|
|
btn.id = 'chat-footer-btn';
|
|
btn.title = 'chat';
|
|
btn.innerHTML = `
|
|
<i class="ph ph-chats-circle text-sm"></i>
|
|
<span class="chat-footer-label">chat</span>
|
|
<span id="chat-unread-count" class="chat-unread-dot"></span>
|
|
`;
|
|
btn.addEventListener('click', toggleFlyout);
|
|
rightGroup.appendChild(btn);
|
|
}
|
|
|
|
function setUnreadBadge(n) {
|
|
const btn = $('chat-footer-btn');
|
|
if (!btn) return;
|
|
if (n > 0) {
|
|
btn.classList.add('has-unread');
|
|
} else {
|
|
btn.classList.remove('has-unread');
|
|
btn.classList.remove('blinking');
|
|
}
|
|
}
|
|
|
|
function startBlink() {
|
|
if (isOpen()) return;
|
|
$('chat-footer-btn')?.classList.add('blinking');
|
|
}
|
|
|
|
function stopBlink() {
|
|
$('chat-footer-btn')?.classList.remove('blinking');
|
|
}
|
|
|
|
// ── Flyout open / close ───────────────────────────────────────────
|
|
|
|
async function toggleFlyout() {
|
|
if (isOpen()) closeFlyout();
|
|
else await openFlyout();
|
|
}
|
|
|
|
async function openFlyout() {
|
|
$('chat-flyout').classList.remove('hidden');
|
|
// Capture blink state BEFORE we clear it — it tells us a peer wrote since last close
|
|
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
|
|
stopBlink();
|
|
pollIntervalMs = POLL_OPEN_MS;
|
|
restartPoll();
|
|
await loadConversations();
|
|
await pollOnce(); // immediate refresh on open
|
|
await loadConversations();
|
|
|
|
// Pick the right initial view:
|
|
// 1. If blinking → newest peer with unread (= someone wrote to us)
|
|
// 2. Else → last peer the user opened manually (persisted)
|
|
// 3. Else → list view
|
|
let target = null;
|
|
if (wasBlinking) {
|
|
const unread = conversations
|
|
.filter(c => parseInt(c.unread) > 0)
|
|
.sort((a, b) => String(b.last_at).localeCompare(String(a.last_at)));
|
|
if (unread.length) target = unread[0].peer;
|
|
}
|
|
if (!target) {
|
|
const lastPeer = localStorage.getItem('chat-last-peer');
|
|
if (lastPeer && conversations.some(c => c.peer === lastPeer)) target = lastPeer;
|
|
}
|
|
if (target) await openConversation(target);
|
|
else showListView();
|
|
}
|
|
|
|
function closeFlyout() {
|
|
$('chat-flyout').classList.add('hidden');
|
|
pollIntervalMs = POLL_CLOSED_MS;
|
|
restartPoll();
|
|
}
|
|
|
|
// ── Views ─────────────────────────────────────────────────────────
|
|
|
|
function showListView() {
|
|
$('chat-list-view').classList.remove('hidden');
|
|
$('chat-thread-view').classList.add('hidden');
|
|
$('chat-pick-view').classList.add('hidden');
|
|
$('chat-flyout-title').textContent = 'chat';
|
|
currentPeer = null;
|
|
}
|
|
|
|
function showThreadView(peer) {
|
|
$('chat-list-view').classList.add('hidden');
|
|
$('chat-thread-view').classList.remove('hidden');
|
|
$('chat-pick-view').classList.add('hidden');
|
|
$('chat-flyout-title').textContent = 'chat';
|
|
$('chat-thread-peer').textContent = peer;
|
|
currentPeer = peer;
|
|
}
|
|
|
|
function showPickView() {
|
|
$('chat-list-view').classList.add('hidden');
|
|
$('chat-thread-view').classList.add('hidden');
|
|
$('chat-pick-view').classList.remove('hidden');
|
|
}
|
|
|
|
// ── Conversation list ─────────────────────────────────────────────
|
|
|
|
async function loadConversations() {
|
|
const res = await fetch('api/chat/conversations').then(r => r.json()).catch(() => null);
|
|
if (!res) return;
|
|
conversations = res.conversations || [];
|
|
setUnreadBadge(res.total_unread || 0);
|
|
renderConversations();
|
|
}
|
|
|
|
function renderConversations() {
|
|
const list = $('chat-list');
|
|
const empty = $('chat-empty');
|
|
if (!list || !empty) return;
|
|
if (!conversations.length) {
|
|
list.innerHTML = '';
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
list.innerHTML = conversations.map(c => {
|
|
const initial = (c.peer || '?').charAt(0).toUpperCase();
|
|
const unreadBadge = parseInt(c.unread) > 0
|
|
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
|
const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview';
|
|
return `
|
|
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}">
|
|
<div class="chat-list-avatar">${escapeHtml(initial)}</div>
|
|
<div class="chat-list-info">
|
|
<div class="chat-list-peer">
|
|
<span>${escapeHtml(c.peer)}</span>
|
|
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
|
</div>
|
|
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div>
|
|
</div>
|
|
${unreadBadge}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
list.querySelectorAll('.chat-list-row').forEach(row => {
|
|
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
|
});
|
|
}
|
|
|
|
// ── Thread ────────────────────────────────────────────────────────
|
|
|
|
async function openConversation(peer) {
|
|
showThreadView(peer);
|
|
localStorage.setItem('chat-last-peer', peer);
|
|
await loadThread(peer);
|
|
// Mark all incoming as read
|
|
await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {});
|
|
// Refresh badges in the background
|
|
loadConversations();
|
|
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
|
}
|
|
|
|
async function loadThread(peer) {
|
|
const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer))
|
|
.then(r => r.json()).catch(() => null);
|
|
if (!res) return;
|
|
currentMessages = res.messages || [];
|
|
renderThread();
|
|
}
|
|
|
|
function renderThread() {
|
|
const wrap = $('chat-thread-messages');
|
|
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 = '<i class="ph ph-checks chat-receipt seen"></i>';
|
|
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
|
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
|
}
|
|
return `
|
|
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
|
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
|
`;
|
|
}).join('');
|
|
// Scroll to bottom
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
|
|
async function sendMessage(e) {
|
|
e?.preventDefault();
|
|
const input = $('chat-thread-input');
|
|
const body = input.value.trim();
|
|
if (!body || !currentPeer) return;
|
|
const sendBtn = $('chat-thread-send');
|
|
sendBtn.disabled = true;
|
|
input.disabled = true;
|
|
|
|
try {
|
|
const res = await hyprcrd.api('api/chat/send', {
|
|
method: 'POST',
|
|
body: { peer: currentPeer, body },
|
|
});
|
|
if (!res.ok) {
|
|
const detail = [
|
|
res.error || 'unknown',
|
|
res.url ? `URL: ${res.url}` : null,
|
|
res.response_body ? `Body: ${res.response_body}` : null,
|
|
].filter(Boolean).join('\n');
|
|
await hyprcrd.dialog.show({
|
|
title: 'send failed',
|
|
message: detail,
|
|
danger: true,
|
|
okLabel: 'Ok',
|
|
cancelLabel: '',
|
|
});
|
|
return;
|
|
}
|
|
input.value = '';
|
|
await loadThread(currentPeer);
|
|
await loadConversations();
|
|
} catch (err) {
|
|
await hyprcrd.dialog.show({
|
|
title: 'send error',
|
|
message: String(err?.message || err),
|
|
danger: true,
|
|
okLabel: 'Ok',
|
|
cancelLabel: '',
|
|
});
|
|
} finally {
|
|
sendBtn.disabled = false;
|
|
input.disabled = false;
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
// ── Pick recipient ────────────────────────────────────────────────
|
|
|
|
async function openPickView() {
|
|
showPickView();
|
|
const list = $('chat-pick-list');
|
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
|
|
|
if (!recipientCache) {
|
|
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
|
if (!res?.ok) {
|
|
list.innerHTML = `<div class="px-3 py-3 text-[10px] text-t-danger font-pixel">${escapeHtml(res?.error || 'failed to load recipients')}</div>`;
|
|
return;
|
|
}
|
|
recipientCache = res;
|
|
}
|
|
|
|
const tenants = recipientCache.tenants || [];
|
|
const self = recipientCache.self;
|
|
const others = tenants.filter(t => t.username !== self);
|
|
if (!others.length) {
|
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = others.map(t => `
|
|
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}">
|
|
<i class="ph ph-user-circle text-sm"></i>
|
|
<span>${escapeHtml(t.username)}</span>
|
|
</div>
|
|
`).join('');
|
|
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
|
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
|
});
|
|
}
|
|
|
|
// ── Polling ───────────────────────────────────────────────────────
|
|
|
|
async function pollOnce() {
|
|
const icon = $('chat-refresh-icon');
|
|
if (icon && isOpen()) icon.classList.add('card-spinner');
|
|
try {
|
|
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
|
|
if (isOpen() && currentPeer) {
|
|
await loadThread(currentPeer);
|
|
hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } })
|
|
.then(() => loadConversations()).catch(() => {});
|
|
}
|
|
// 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();
|
|
}
|
|
initialPollDone = true;
|
|
} finally {
|
|
if (icon) icon.classList.remove('card-spinner');
|
|
}
|
|
}
|
|
|
|
function restartPoll() {
|
|
if (pollHandle) clearInterval(pollHandle);
|
|
pollHandle = setInterval(pollOnce, pollIntervalMs);
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────
|
|
|
|
function init() {
|
|
if (!$('chat-flyout')) return;
|
|
|
|
injectFooterButton();
|
|
|
|
$('chat-close-btn').addEventListener('click', closeFlyout);
|
|
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
|
$('chat-new-btn').addEventListener('click', openPickView);
|
|
$('chat-empty-start')?.addEventListener('click', openPickView);
|
|
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
|
$('chat-pick-back').addEventListener('click', showListView);
|
|
$('chat-thread-form').addEventListener('submit', sendMessage);
|
|
|
|
// Esc closes flyout when focus is inside it
|
|
$('chat-flyout').addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
|
});
|
|
|
|
// Defer the first POST (poll) until auth has set csrf. GETs are fine immediately.
|
|
loadConversations().catch(() => {});
|
|
const startPolling = () => {
|
|
pollOnce().catch(() => {});
|
|
restartPoll();
|
|
};
|
|
if (hyprcrd.util.csrf?.()) {
|
|
startPolling();
|
|
} else {
|
|
hyprcrd.hooks.on('csrf.ready', startPolling);
|
|
}
|
|
|
|
// Pause polling when the page is hidden
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
|
} else {
|
|
pollOnce().catch(() => {});
|
|
restartPoll();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
hyprcrd.registerPlugin?.({
|
|
name: 'chat',
|
|
version: '1.0.0',
|
|
description: 'Encrypted chat between relay tenants',
|
|
});
|