Files
hyprcrd/public/plugins/chat/chat.js
T
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +02:00

432 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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();
}
});
// Also refresh on the global sync heartbeat. Our own timer keeps
// the faster flyout-open cadence (UX-specific); tick guarantees
// that the header Sync button refreshes chat too, even when the
// flyout is closed and our own timer sits at 20s.
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'chat',
version: '1.0.0',
description: 'Encrypted chat between relay tenants',
});