Files
hyprcrd/public/plugins/chat/chat.js
T
thandClaude Opus 4.6 81d9ce0e87 chat: peer-switch strip, back button moved to flyout header
Switching conversations used to require bouncing back to the list view
and picking from there. Now the thread view has a horizontal peer
strip at the top: one click hops to another conversation, active tab
is highlighted and auto-scrolls into view, unread peers show a dot.
Scrollbar is hidden — vertical wheel delta is redirected to horizontal
scroll so the user can slide the strip with the mouse wheel when it
overflows.

In-thread header is gone; the back button moved next to "chat" in the
flyout header and only shows while a thread is open. Peer name stays
visible via the active tab.

Bumps to 1.9.3.

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

488 lines
19 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';
$('chat-header-back')?.classList.add('hidden');
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-header-back')?.classList.remove('hidden');
currentPeer = peer;
// Re-render tabs so the active highlight follows the thread switch.
renderPeerTabs();
}
function showPickView() {
$('chat-list-view').classList.add('hidden');
$('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.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 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));
});
}
// ── 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;
// Fold the current peer in even if no messages exist yet (fresh
// pick-view selection), so switching back to it via tab is always
// possible.
const peers = conversations.slice();
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
peers.unshift({ peer: currentPeer, unread: 0 });
}
if (!peers.length) { strip.innerHTML = ''; return; }
strip.innerHTML = peers.map(c => {
const active = c.peer === currentPeer ? ' active' : '';
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
? '<span class="chat-peer-dot"></span>' : '';
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
}).join('');
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const peer = tab.dataset.peer;
if (peer && peer !== currentPeer) openConversation(peer);
});
});
// Keep the active tab in view after a switch so the user can see
// what's selected when the list overflows.
const active = strip.querySelector('.chat-peer-tab.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// Redirect vertical wheel scrolls to horizontal scroll on the peer
// strip. Attached once on init — preventDefault only when we actually
// consume the delta, so a trackpad user's native horizontal gesture
// still works.
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(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-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
$('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage);
wirePeerStripWheel();
// 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',
});