// Floating messages bubble (bottom-right, every page except /messages):
// unread badge, mini panel with conversation list ↔ thread view and a quick
// plain-text composer. Reuses the conversations API + live WS channel.
import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
import { attachMentions } from '/static/js/mention.js';
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
let meId = '';
let open = false;
let current = null; // conversation object when in thread view
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
const fab = document.createElement('button');
fab.className = 'chat-fab';
fab.setAttribute('aria-label', 'Messages');
fab.innerHTML = '✉';
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.className = 'chat-panel';
panel.hidden = true;
panel.innerHTML = `
No conversations yet — start one from the Messages page.
'; } } async function renderMsg(m) { const div = document.createElement('div'); div.className = 'cw-msg' + (m.senderId === meId ? ' own' : ''); div.innerHTML = `${esc(await userName(m.senderId))}
📎 ${m.attachments.length} attachment(s)
` : ''); return div; } async function showThread(c) { current = c; panel.querySelector('#cw-back').hidden = false; panel.querySelector('#cw-footer').hidden = false; panel.querySelector('#cw-title').textContent = c.title; body.replaceChildren(); const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`); for (const m of res.messages) body.appendChild(await renderMsg(m)); body.scrollTop = body.scrollHeight; api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {}); refreshUnread(); panel.querySelector('#cw-input').focus(); } async function send() { const input = panel.querySelector('#cw-input'); const text = input.value.trim(); if (!text || !current) return; input.value = ''; try { await api('POST', `/api/v1/conversations/${current.id}/messages`, { body: '' + esc(text) + '
', }); } catch (e) { input.value = text; } } panel.querySelector('#cw-send').addEventListener('click', send); const cwInput = panel.querySelector('#cw-input'); cwInput.addEventListener('keydown', (e) => { if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter if (e.key === 'Enter') { e.preventDefault(); send(); } }); attachMentions(cwInput); fab.addEventListener('click', async () => { open = !open; panel.hidden = !open; if (open) { if (!meId) { try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ } } showList(); } }); panel.querySelector('#cw-close').addEventListener('click', () => { open = false; panel.hidden = true; }); panel.querySelector('#cw-back').addEventListener('click', showList); subscribe('chat', async (event, data) => { if (event !== 'message' || !data) return; if (open && current && data.conversationId === current.id) { body.appendChild(await renderMsg(data)); body.scrollTop = body.scrollHeight; api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {}); } else if (open && !current) { showList(); } else { refreshUnread(); } }); onPollFallback(refreshUnread); refreshUnread(); }