// ── 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. // // Threads come in two kinds: // peer — classic 1:1 with a single relay tenant. Disposable flag per // thread drops messages from the relay after delivery. // room — named group chat with a member list. Always persistent on // the relay so every device / late joiner has full history. const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing'); const $ = (id) => document.getElementById(id); // ── State ───────────────────────────────────────────────────────── let conversations = []; // Null = list view. Otherwise: { kind: 'peer'|'room', key, label }. let currentThread = null; let currentMessages = []; let pollHandle = null; let pollIntervalMs = 20000; let recipientCache = null; // { tenants: [...], self: '...' } let initialPollDone = false; // Group creation pick-view state. Reset on every pick-view open. let pickMode = 'peer'; // 'peer' or 'group' let selectedMembers = new Set(); 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'); } function threadFromItem(item) { return { kind: item.kind || 'peer', key: item.key ?? item.peer, label: item.label ?? item.peer }; } function sameThread(a, b) { return a && b && a.kind === b.kind && a.key === b.key; } function threadBody(thread) { return thread.kind === 'room' ? { room_uid: thread.key } : { peer: thread.key }; } function threadStorageKey(thread) { return `${thread.kind}:${thread.key}`; } function parseStorageKey(str) { if (!str) return null; const i = str.indexOf(':'); if (i < 0) return { kind: 'peer', key: str, label: str }; return { kind: str.slice(0, i), key: str.slice(i + 1), label: str.slice(i + 1) }; } async function getSelfUsername() { if (recipientCache?.self) return recipientCache.self; const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null); if (res?.ok) recipientCache = res; return recipientCache?.self || ''; } // ── Footer button ───────────────────────────────────────────────── function injectFooterButton() { const footer = document.querySelector('footer'); if (!footer) return; if (document.getElementById('chat-footer-btn')) return; 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'); const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking'); stopBlink(); pollIntervalMs = POLL_OPEN_MS; restartPoll(); await loadConversations(); await pollOnce(); await loadConversations(); // Initial view: newest unread thread after a blink, else the last // thread the user opened (peer or room), else the list. 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 = threadFromItem(unread[0]); } if (!target) { const remembered = parseStorageKey(localStorage.getItem('chat-last-thread')); if (remembered && conversations.some(c => c.kind === remembered.kind && c.key === remembered.key)) { const item = conversations.find(c => c.kind === remembered.kind && c.key === remembered.key); target = threadFromItem(item); } } if (target) await openConversation(target); else showListView(); } function closeFlyout() { $('chat-flyout').classList.add('hidden'); pollIntervalMs = POLL_CLOSED_MS; restartPoll(); } // ── Views ───────────────────────────────────────────────────────── function hideAllViews() { ['chat-list-view', 'chat-thread-view', 'chat-pick-view', 'chat-room-info-view'] .forEach(id => $(id)?.classList.add('hidden')); } function showListView() { hideAllViews(); $('chat-list-view').classList.remove('hidden'); $('chat-flyout-title').textContent = 'chat'; $('chat-header-back')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('hidden'); currentThread = null; } function showThreadView(thread) { hideAllViews(); $('chat-thread-view').classList.remove('hidden'); $('chat-flyout-title').textContent = 'chat'; $('chat-header-back')?.classList.remove('hidden'); currentThread = thread; // Room-only: info icon. 1:1-only: disposable icon. $('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room'); $('chat-thread-disposable')?.classList.toggle('hidden', thread.kind !== 'peer'); if (thread.kind === 'peer') refreshDisposableIcon(thread.key); renderPeerTabs(); } function showPickView() { hideAllViews(); $('chat-pick-view').classList.remove('hidden'); $('chat-header-back')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('hidden'); } function showRoomInfoView() { hideAllViews(); $('chat-room-info-view').classList.remove('hidden'); $('chat-header-back')?.classList.add('hidden'); $('chat-thread-disposable')?.classList.add('hidden'); $('chat-thread-info')?.classList.add('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; renderPeerTabs(); if (!conversations.length) { list.innerHTML = ''; empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); list.innerHTML = conversations.map(c => { const isRoom = c.kind === 'room'; const label = c.label || c.key || '?'; const initial = label.charAt(0).toUpperCase(); const avatar = isRoom ? '
' : `
${escapeHtml(initial)}
`; const unreadBadge = parseInt(c.unread) > 0 ? `${c.unread}` : ''; const previewClass = (!isRoom && c.last_direction === 'out') ? 'chat-list-preview outgoing' : 'chat-list-preview'; const preview = isRoom && c.last_author ? `${escapeHtml(c.last_author)}: ${escapeHtml((c.last_body || '').slice(0, 70))}` : escapeHtml((c.last_body || '').slice(0, 80)); return `
${avatar}
${escapeHtml(label)} ${escapeHtml(formatChatTime(c.last_at))}
${preview}
${unreadBadge}
`; }).join(''); list.querySelectorAll('.chat-list-row').forEach(row => { row.addEventListener('click', () => { const item = conversations.find(c => c.kind === row.dataset.kind && c.key === row.dataset.key); if (item) openConversation(threadFromItem(item)); }); }); } // ── Peer tabs (thread-view quick-switch strip) ──────────────────── // Horizontal row above the thread so the user can hop between // conversations without going back to the list. Hidden scrollbar; // vertical wheel delta is redirected to horizontal scroll. function renderPeerTabs() { const strip = $('chat-thread-peers'); if (!strip) return; const tabs = conversations.slice(); // Fold the current thread in even if it's brand-new (e.g. just // created a room and haven't synced conversations yet), so the // active tab is always visible. if (currentThread && !tabs.some(c => c.kind === currentThread.kind && c.key === currentThread.key)) { tabs.unshift({ kind: currentThread.kind, key: currentThread.key, label: currentThread.label, unread: 0 }); } if (!tabs.length) { strip.innerHTML = ''; return; } strip.innerHTML = tabs.map(c => { const isRoom = c.kind === 'room'; const active = currentThread && currentThread.kind === c.kind && currentThread.key === c.key ? ' active' : ''; const notActive = !active; const dot = parseInt(c.unread) > 0 && notActive ? '' : ''; const icon = isRoom ? '' : ''; const label = c.label || c.key || '?'; return ``; }).join(''); strip.querySelectorAll('.chat-peer-tab').forEach(tab => { tab.addEventListener('click', () => { const thread = { kind: tab.dataset.kind, key: tab.dataset.key, label: tab.querySelector('span')?.textContent || tab.dataset.key }; if (!sameThread(currentThread, thread)) openConversation(thread); }); }); const active = strip.querySelector('.chat-peer-tab.active'); if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } function wirePeerStripWheel() { const strip = $('chat-thread-peers'); if (!strip || strip.dataset.wheelWired) return; strip.dataset.wheelWired = '1'; strip.addEventListener('wheel', (e) => { if (e.deltaY === 0) return; if (strip.scrollWidth <= strip.clientWidth) return; e.preventDefault(); strip.scrollLeft += e.deltaY; }, { passive: false }); } // ── Thread ──────────────────────────────────────────────────────── async function openConversation(thread) { showThreadView(thread); localStorage.setItem('chat-last-thread', threadStorageKey(thread)); await loadThread(thread); // Backfill older messages from the relay on open so a second device / // cold cache catches up. Fire-and-forget: the re-render happens via // the subsequent conversations refresh and the next poll tick. backfillThread(thread).catch(() => {}); await hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(thread) }).catch(() => {}); loadConversations(); setTimeout(() => $('chat-thread-input')?.focus(), 50); } async function loadThread(thread) { const qs = thread.kind === 'room' ? 'room_uid=' + encodeURIComponent(thread.key) : 'peer=' + encodeURIComponent(thread.key); const res = await fetch('api/chat/messages?' + qs).then(r => r.json()).catch(() => null); if (!res) return; currentMessages = res.messages || []; await renderThread(); } async function backfillThread(thread) { const body = threadBody(thread); // Oldest id we have locally is the lower bound for the backfill — // we want messages BEFORE it. const lowest = currentMessages.reduce((min, m) => { const id = Number(m.id || 0); return min === 0 || (id > 0 && id < min) ? id : min; }, 0); const res = await hyprcrd.api('api/chat/backfill', { method: 'POST', body: { ...body, before: lowest, limit: 100 }, }).catch(() => null); if (res?.ok && (res.backfilled || 0) > 0 && sameThread(thread, currentThread)) { await loadThread(thread); } } async function renderThread() { const wrap = $('chat-thread-messages'); if (!wrap) return; const self = await getSelfUsername(); const isRoom = currentThread?.kind === 'room'; let lastAuthor = null; wrap.innerHTML = currentMessages.map(m => { // For groups: author == self → "out" bubble regardless of the // stored direction (which reflects which device inserted the row). const mine = isRoom ? (m.peer === self) : (m.direction === 'out'); const cls = mine ? 'out' : 'in'; let receipt = ''; if (!isRoom && mine) { if (Number(m.seen)) receipt = ''; else if (Number(m.delivered)) receipt = ''; else receipt = ''; } let authorLine = ''; if (isRoom && !mine && m.peer !== lastAuthor) { authorLine = `
${escapeHtml(m.peer || '')}
`; } lastAuthor = m.peer; return ` ${authorLine}
${escapeHtml(m.body)}
${escapeHtml(formatChatTime(m.sent_at))}${receipt}
`; }).join(''); wrap.scrollTop = wrap.scrollHeight; } async function sendMessage(e) { e?.preventDefault(); const input = $('chat-thread-input'); const body = input.value.trim(); if (!body || !currentThread) return; const sendBtn = $('chat-thread-send'); sendBtn.disabled = true; input.disabled = true; try { const res = await hyprcrd.api('api/chat/send', { method: 'POST', body: { ...threadBody(currentThread), 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(currentThread); 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 / group creation ─────────────────────────────── async function openPickView() { showPickView(); pickMode = 'peer'; selectedMembers = new Set(); applyPickMode(); $('chat-group-name').value = ''; 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; } renderPickList(); } function applyPickMode() { const nameInput = $('chat-group-name'); const actions = $('chat-group-actions'); const title = $('chat-pick-title'); document.querySelectorAll('.chat-pick-mode-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === pickMode); }); if (pickMode === 'group') { nameInput.classList.remove('hidden'); actions.classList.remove('hidden'); if (title) title.textContent = 'new group'; updateGroupCount(); } else { nameInput.classList.add('hidden'); actions.classList.add('hidden'); if (title) title.textContent = 'new conversation'; } renderPickList(); } function renderPickList() { const list = $('chat-pick-list'); if (!list || !recipientCache) return; const self = recipientCache.self; const others = (recipientCache.tenants || []).filter(t => t.username !== self); if (!others.length) { list.innerHTML = ''; return; } list.innerHTML = others.map(t => { const selected = selectedMembers.has(t.username); const selCls = (pickMode === 'group' && selected) ? ' selected' : ''; const check = pickMode === 'group' ? `` : ''; return `
${escapeHtml(t.username)} ${check}
`; }).join(''); list.querySelectorAll('.chat-pick-row').forEach(row => { row.addEventListener('click', () => { const u = row.dataset.peer; if (pickMode === 'group') { if (selectedMembers.has(u)) selectedMembers.delete(u); else selectedMembers.add(u); renderPickList(); updateGroupCount(); } else { openConversation({ kind: 'peer', key: u, label: u }); } }); }); } function updateGroupCount() { const el = $('chat-group-count'); if (!el) return; const n = selectedMembers.size; el.textContent = n ? `${n} member${n === 1 ? '' : 's'} selected` : 'pick at least one member'; } async function createRoom() { const name = $('chat-group-name').value.trim(); if (!name) { await hyprcrd.dialog.show({ title: 'missing name', message: 'Give the group a name.', okLabel: 'Ok', cancelLabel: '' }); return; } if (!selectedMembers.size) { await hyprcrd.dialog.show({ title: 'no members', message: 'Pick at least one other member.', okLabel: 'Ok', cancelLabel: '' }); return; } const res = await hyprcrd.api('api/chat/rooms', { method: 'POST', body: { name, members: [...selectedMembers] }, }); if (!res?.ok) { await hyprcrd.dialog.show({ title: 'create failed', message: res?.error || 'unknown error', danger: true, okLabel: 'Ok', cancelLabel: '', }); return; } await loadConversations(); openConversation({ kind: 'room', key: res.uid, label: name }); } // ── Room info / member management ────────────────────────────── async function openRoomInfo() { if (currentThread?.kind !== 'room') return; const savedThread = currentThread; showRoomInfoView(); $('chat-room-info-name').textContent = savedThread.label || savedThread.key; const body = $('chat-room-info-body'); body.innerHTML = '
loading...
'; const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null); const room = (res?.rooms || []).find(r => r.uid === savedThread.key); const self = await getSelfUsername(); if (!room) { body.innerHTML = '
room not found
'; return; } const isOwner = room.owner_username === self; const others = (res?.tenants || recipientCache?.tenants || []) .filter(t => t.username !== self && !(room.members || []).includes(t.username)); const memberRows = (room.members || []).map(u => { const ownerTag = u === room.owner_username ? 'owner' : ''; const removeBtn = (isOwner && u !== room.owner_username) ? `` : ''; return `
${escapeHtml(u)} ${ownerTag} ${removeBtn}
`; }).join(''); const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length ? `
Add members
${ (recipientCache?.tenants || []) .filter(t => t.username !== self && !(room.members || []).includes(t.username)) .map(t => `
${escapeHtml(t.username)}
`) .join('') || '
everyone on the relay is already in
' }
` : ''; body.innerHTML = `
Members (${(room.members || []).length})
${memberRows || '
no members
'}
${addSection}
${isOwner ? '' : '' }
`; body.querySelectorAll('button[data-action]').forEach(btn => { btn.addEventListener('click', () => handleRoomAction(btn.dataset.action, btn.dataset.user, savedThread)); }); } async function handleRoomAction(action, user, thread) { const uid = thread.key; if (action === 'back') { openConversation(thread); return; } if (action === 'remove' || action === 'add') { // Fetch current room, mutate members, PATCH. const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null); const room = (res?.rooms || []).find(r => r.uid === uid); if (!room) return; let members = [...(room.members || [])]; if (action === 'remove') members = members.filter(m => m !== user); else if (!members.includes(user)) members.push(user); // Relay adds owner automatically, but we forward the full list // minus the owner (owner ALWAYS stays on relay side). const patchRes = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'PATCH', body: { members }, }); if (!patchRes?.ok) { await hyprcrd.dialog.show({ title: 'update failed', message: patchRes?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' }); } await pollOnce(); // refresh local rooms mirror await openRoomInfo(); // re-render return; } if (action === 'leave' || action === 'delete') { const confirm = await hyprcrd.dialog.show({ title: action === 'delete' ? 'Delete room?' : 'Leave room?', message: action === 'delete' ? 'All members lose access and the message history is wiped.' : 'You will stop receiving messages in this room.', danger: true, okLabel: action === 'delete' ? 'Delete' : 'Leave', }); if (!confirm) return; const res = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'DELETE' }); if (!res?.ok) { await hyprcrd.dialog.show({ title: 'failed', message: res?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' }); return; } await pollOnce(); await loadConversations(); showListView(); } } // ── Disposable toggle (1:1) ────────────────────────────────────── async function refreshDisposableIcon(peer) { const btn = $('chat-thread-disposable'); if (!btn) return; const res = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer)) .then(r => r.json()).catch(() => null); const on = !!res?.disposable; btn.classList.toggle('active', on); btn.setAttribute('data-tip', on ? 'disposable: new messages drop after delivery — click to persist' : 'persistent: messages live on the relay — click to make disposable'); } async function toggleDisposable() { if (currentThread?.kind !== 'peer') return; const peer = currentThread.key; const cur = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer)) .then(r => r.json()).catch(() => ({ disposable: 0 })); const next = cur.disposable ? 0 : 1; await hyprcrd.api('api/chat/thread-settings', { method: 'POST', body: { peer, disposable: next }, }); refreshDisposableIcon(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 (isOpen() && currentThread) { await loadThread(currentThread); hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(currentThread) }) .then(() => loadConversations()).catch(() => {}); } if (!isOpen()) startBlink(); } else if (receiptUpdates > 0 && isOpen() && currentThread) { await loadThread(currentThread); } else if (initialPollDone === false) { 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-header-back').addEventListener('click', () => { showListView(); loadConversations(); }); $('chat-pick-back').addEventListener('click', showListView); $('chat-thread-form').addEventListener('submit', sendMessage); $('chat-thread-info')?.addEventListener('click', openRoomInfo); $('chat-thread-disposable')?.addEventListener('click', toggleDisposable); $('chat-room-info-back')?.addEventListener('click', () => { if (currentThread) openConversation(currentThread); else showListView(); }); $('chat-group-create')?.addEventListener('click', createRoom); document.querySelectorAll('.chat-pick-mode-btn').forEach(btn => { btn.addEventListener('click', () => { pickMode = btn.dataset.mode; applyPickMode(); }); }); wirePeerStripWheel(); $('chat-flyout').addEventListener('keydown', e => { if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); } }); loadConversations().catch(() => {}); const startPolling = () => { pollOnce().catch(() => {}); restartPoll(); }; if (hyprcrd.util.csrf?.()) { startPolling(); } else { hyprcrd.hooks.on('csrf.ready', startPolling); } document.addEventListener('visibilitychange', () => { if (document.hidden) { if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } } else { pollOnce().catch(() => {}); restartPoll(); } }); hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } hyprcrd.registerPlugin?.({ name: 'chat', version: '1.1.0', description: 'Encrypted chat between relay tenants (1:1 + group rooms)', });