// 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 = `
Messages
`; document.body.appendChild(panel); const body = panel.querySelector('#cw-body'); const unreadBadge = fab.querySelector('#cw-unread'); async function userName(id) { if (!userCache.has(id)) { try { const res = await api('GET', `/api/v1/users/${id}/card`); userCache.set(id, res.card.name); } catch (e) { userCache.set(id, '…'); } } return userCache.get(id); } async function refreshUnread() { try { const res = await api('GET', '/api/v1/conversations'); const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0); unreadBadge.textContent = total > 9 ? '9+' : String(total); unreadBadge.hidden = total === 0; return res.conversations; } catch (e) { return []; } } async function showList() { current = null; panel.querySelector('#cw-back').hidden = true; panel.querySelector('#cw-footer').hidden = true; panel.querySelector('#cw-title').textContent = 'Messages'; const convs = await refreshUnread(); body.replaceChildren(...convs.map((c) => { const b = document.createElement('button'); b.className = 'cw-conv'; b.innerHTML = `${esc(c.title)} · ${esc(c.kind)} ${c.unread ? `${c.unread}` : ''}`; b.addEventListener('click', () => showThread(c)); return b; })); if (!convs.length) { body.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.body || ''}
` + ((m.attachments || []).length ? `

📎 ${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(); }