Files
hyprcrd/public/plugins/chat/chat.js
T
thandClaude Opus 4.6 9267c7c6a1 chat: group chat UI — room tabs, creation flow, member mgmt
Generalises the flyout from peer-only to a thread abstraction
({kind: 'peer'|'room', key, label}). Conversations list and peer
strip render rooms with a group icon alongside 1:1 peers; clicking
either opens the same thread view.

New UI surface:
  - Pick view gets a 1:1 / group mode switch. Group mode reveals a
    name field + multi-select member list and a Create button.
  - Room threads show an info icon in the flyout header that opens a
    member list; owners can add/remove members, rename-later, or
    delete the room; non-owners can leave.
  - 1:1 threads show a disposable toggle (ph-fire) in the header;
    flipping it tells the relay to drop future sends after delivery
    instead of persisting.
  - Group bubbles show the author's username above each message when
    it changes, matching common chat UX.

Thread open triggers a backfill fetch so cold caches / second
devices catch up on older history from the relay.

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

809 lines
31 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.
//
// 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, '&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');
}
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 = `
<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');
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
? '<div class="chat-list-avatar"><i class="ph ph-users-three"></i></div>'
: `<div class="chat-list-avatar">${escapeHtml(initial)}</div>`;
const unreadBadge = parseInt(c.unread) > 0
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
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 `
<div class="chat-list-row" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}">
${avatar}
<div class="chat-list-info">
<div class="chat-list-peer">
<span>${escapeHtml(label)}</span>
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
</div>
<div class="${previewClass}">${preview}</div>
</div>
${unreadBadge}
</div>
`;
}).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
? '<span class="chat-peer-dot"></span>' : '';
const icon = isRoom
? '<i class="ph ph-users-three chat-peer-tab-icon"></i>'
: '';
const label = c.label || c.key || '?';
return `<button class="chat-peer-tab${active}" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}" title="${escapeHtml(label)}">${dot}${icon}<span>${escapeHtml(label)}</span></button>`;
}).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 = '<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>';
}
let authorLine = '';
if (isRoom && !mine && m.peer !== lastAuthor) {
authorLine = `<div class="chat-bubble-author">${escapeHtml(m.peer || '')}</div>`;
}
lastAuthor = m.peer;
return `
${authorLine}
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
`;
}).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 = '<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;
}
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 = '<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 => {
const selected = selectedMembers.has(t.username);
const selCls = (pickMode === 'group' && selected) ? ' selected' : '';
const check = pickMode === 'group'
? `<i class="ph ph-check chat-pick-check"></i>`
: '';
return `
<div class="chat-pick-row${selCls}" data-peer="${escapeHtml(t.username)}">
<i class="ph ph-user-circle text-sm"></i>
<span>${escapeHtml(t.username)}</span>
${check}
</div>
`;
}).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 = '<div class="text-[10px] text-t-dim font-pixel">loading...</div>';
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 = '<div class="text-[10px] text-t-danger font-pixel">room not found</div>';
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 ? '<span class="owner-badge">owner</span>' : '';
const removeBtn = (isOwner && u !== room.owner_username)
? `<button class="btn danger" data-action="remove" data-user="${escapeHtml(u)}" style="margin-left:auto;padding:2px 8px;">remove</button>`
: '';
return `
<div class="chat-room-member-row">
<i class="ph ph-user-circle text-t-accent"></i>
<span>${escapeHtml(u)}</span>
${ownerTag}
${removeBtn}
</div>
`;
}).join('');
const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length
? `<div class="section-label">Add members</div>
<div id="chat-room-add-list">${
(recipientCache?.tenants || [])
.filter(t => t.username !== self && !(room.members || []).includes(t.username))
.map(t => `<div class="chat-room-member-row"><i class="ph ph-user-circle"></i><span>${escapeHtml(t.username)}</span><button class="btn" style="margin-left:auto;padding:2px 8px;" data-action="add" data-user="${escapeHtml(t.username)}">add</button></div>`)
.join('') || '<div class="text-[10px] text-t-dim font-pixel">everyone on the relay is already in</div>'
}</div>`
: '';
body.innerHTML = `
<div>
<div class="section-label">Members (${(room.members || []).length})</div>
${memberRows || '<div class="text-[10px] text-t-dim">no members</div>'}
</div>
${addSection}
<div style="display:flex;gap:8px;margin-top:12px;">
${isOwner
? '<button class="btn danger" data-action="delete" style="flex:1">Delete room</button>'
: '<button class="btn danger" data-action="leave" style="flex:1">Leave room</button>'
}
<button class="btn" data-action="back" style="flex:1">Back to chat</button>
</div>
`;
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)',
});