// ── 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, '''); } 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 = ` chat `; 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 ? `${c.unread}` : ''; const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview'; return `
${escapeHtml(initial)}
${escapeHtml(c.peer)} ${escapeHtml(formatChatTime(c.last_at))}
${escapeHtml((c.last_body || '').slice(0, 80))}
${unreadBadge}
`; }).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 = ''; else if (Number(m.delivered)) receipt = ''; else receipt = ''; } return `
${escapeHtml(m.body)}
${escapeHtml(formatChatTime(m.sent_at))}${receipt}
`; }).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 = '
loading...
'; if (!recipientCache) { const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null); if (!res?.ok) { list.innerHTML = `
${escapeHtml(res?.error || 'failed to load recipients')}
`; return; } recipientCache = res; } const tenants = recipientCache.tenants || []; const self = recipientCache.self; const others = tenants.filter(t => t.username !== self); if (!others.length) { list.innerHTML = ''; return; } list.innerHTML = others.map(t => `
${escapeHtml(t.username)}
`).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', });