195 lines
10 KiB
JavaScript
195 lines
10 KiB
JavaScript
// chat.js — the SimpleX-style CHAT RENDERER for Quantum Forms. Each message the guest sends is a SUBMISSION through
|
|
// the quantum tunnel (POST /baas/sessions/{id}/submit). Backend→guest messages (agent questions, other users, system)
|
|
// arrive through the SAME tunnel over SSE (GET /baas/sessions/{id}/stream). A message IS a form submission; the chat
|
|
// is just one renderer over the same session pipe.
|
|
//
|
|
// Bidirectional over the quantum tunnel: guest→backend = POST-up (sealed submission); backend→guest = SSE-down.
|
|
|
|
const el = (id) => document.getElementById(id)
|
|
const msgs = el('msgs')
|
|
let sessionId = null, token = null, guestId = null, expiresAt = 0
|
|
|
|
function toast(t) { const e = el('toast'); e.textContent = t; e.style.display = 'block'; setTimeout(() => e.style.display = 'none', 2200) }
|
|
|
|
function addBubble(kind, text, meta, fileName) {
|
|
const b = document.createElement('div')
|
|
b.className = 'bubble ' + (kind === 'sent' ? 'sent' : kind === 'sys' ? 'sys' : 'recv')
|
|
if (kind === 'sys') { b.textContent = text; msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight; return b }
|
|
if (text) b.appendChild(document.createTextNode(text))
|
|
if (fileName) {
|
|
const chip = document.createElement('div'); chip.className = 'file-chip'; chip.textContent = '📎 ' + fileName
|
|
b.appendChild(chip)
|
|
}
|
|
if (meta) { const m = document.createElement('span'); m.className = 'meta'; m.textContent = meta; b.appendChild(m) }
|
|
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
|
|
return b
|
|
}
|
|
|
|
function hhmm() { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }
|
|
|
|
// ── countdown timer (session is live until expires_at) ──
|
|
function startTimer() {
|
|
const tick = () => {
|
|
if (!expiresAt) return
|
|
const secs = expiresAt - Math.floor(Date.now() / 1000)
|
|
if (secs <= 0) { el('timer').textContent = 'session ended'; addBubble('sys', 'This session has ended.'); return }
|
|
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60
|
|
el('timer').textContent = 'live · ' + (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's left'
|
|
setTimeout(tick, 1000)
|
|
}
|
|
tick()
|
|
}
|
|
|
|
// ── join by code ──
|
|
async function join() {
|
|
const code = el('code').value.trim().toUpperCase()
|
|
if (!code) { toast('Enter the code'); return }
|
|
el('joinBtn').disabled = true
|
|
try {
|
|
const r = await fetch('/baas/sessions/join-by-code', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code })
|
|
})
|
|
if (!r.ok) { toast(r.status === 404 ? 'No open session for that code' : 'Could not join'); el('joinBtn').disabled = false; return }
|
|
const d = await r.json()
|
|
// ★ DISPATCHER: one entry point (the forms host). Route by the session's KIND to the right renderer —
|
|
// you never go to a different URL; you type the code here and the app loads the correct form type.
|
|
if (d.kind === 'qa-rounds' || d.kind === 'assembly') {
|
|
location.href = '/assembly.html?c=' + encodeURIComponent(code)
|
|
return
|
|
}
|
|
sessionId = d.session_id; token = d.subscribe_token; guestId = d.guest
|
|
expiresAt = d.expires_at || 0
|
|
el('gate').style.display = 'none'; el('chat').style.display = 'flex'
|
|
startTimer()
|
|
addBubble('sys', 'You joined the session. This chat is quantum-resistant end-to-end encrypted.')
|
|
// show the opening prompt ONCE at join (not on every reconnect — that was the repeating-message bug).
|
|
if (d.question) addBubble('recv', d.question, hhmm())
|
|
openStream()
|
|
} catch (e) { toast('Network error'); el('joinBtn').disabled = false }
|
|
}
|
|
|
|
// ── SSE: backend→guest messages through the tunnel ──
|
|
function openStream() {
|
|
if (typeof EventSource === 'undefined') return
|
|
const es = new EventSource(`/baas/sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(token)}`)
|
|
// NOTE: the stream re-sends the opening 'question' on every (re)connect. We already showed it once at join,
|
|
// so we IGNORE the streamed 'question' event here — otherwise it repeats every reconnect (the "keeps telling me
|
|
// to send the file" bug). Only genuine 'message' events (team/agent replies) are shown live.
|
|
es.addEventListener('question', () => { /* intentionally ignored — shown once at join, not on every reconnect */ })
|
|
es.addEventListener('message', (e) => {
|
|
try { const d = JSON.parse(e.data); addBubble('recv', d.text || '', hhmm()) } catch (_) {}
|
|
})
|
|
es.addEventListener('closed', () => { addBubble('sys', 'The host closed this session.'); es.close() })
|
|
es.onerror = () => { /* the browser auto-reconnects EventSource */ }
|
|
}
|
|
|
|
// ── staged attachment (SimpleX pattern: picking a file STAGES it; send transmits it) ──
|
|
let stagedFile = null
|
|
function fmtSize(n) { return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB' }
|
|
function stageFile(file) {
|
|
if (!file) return
|
|
if (file.size > 40 * 1024 * 1024) { toast('File too large (40 MB max)'); return }
|
|
stagedFile = file
|
|
el('pvName').textContent = file.name
|
|
el('pvSize').textContent = fmtSize(file.size)
|
|
el('preview').style.display = 'flex'
|
|
}
|
|
function clearStaged() { stagedFile = null; el('preview').style.display = 'none'; el('fileInput').value = '' }
|
|
|
|
// ── send: transmits the typed message AND/OR the staged file through the tunnel (one action, like SimpleX) ──
|
|
async function send() {
|
|
const ta = el('input'); const text = ta.value.trim()
|
|
if (!text && !stagedFile) return
|
|
el('sendBtn').disabled = true
|
|
|
|
// send the file first (if staged), then the text — each is one submission through the tunnel.
|
|
try {
|
|
if (stagedFile) {
|
|
const file = stagedFile
|
|
// Signal-style: a bubble with a live progress ring + a cancel (✕) button while the raw bytes stream to Garage.
|
|
const bubble = addFileBubble(file.name)
|
|
const ok = await uploadWithProgress(file, bubble)
|
|
if (ok === 'cancelled') { bubble.remove(); toast('Upload cancelled'); el('sendBtn').disabled = false; return }
|
|
setBubbleDone(bubble, ok)
|
|
if (ok) clearStaged(); else { toast('File failed'); el('sendBtn').disabled = false; return }
|
|
}
|
|
if (text) {
|
|
const bubble = addBubble('sent', text, hhmm() + ' · sending…')
|
|
const ok = await submitPayload({ type: 'message', text })
|
|
if (bubble) { const m = bubble.querySelector('.meta'); if (m) m.textContent = ok ? hhmm() + ' · sent ✓' : hhmm() + ' · failed' }
|
|
if (ok) { ta.value = ''; autoGrow() } else toast('Failed to send')
|
|
}
|
|
} catch (e) { toast('Network error') } finally { el('sendBtn').disabled = false }
|
|
}
|
|
|
|
// ── Signal-style file bubble: a progress ring + % + a cancel (✕) button ──
|
|
function addFileBubble(name) {
|
|
const b = document.createElement('div')
|
|
b.className = 'bubble sent filebub'
|
|
b.innerHTML =
|
|
'<div class="fb-row">' +
|
|
'<svg class="fb-ring" viewBox="0 0 36 36"><circle class="fb-bg" cx="18" cy="18" r="15"/>' +
|
|
'<circle class="fb-fg" cx="18" cy="18" r="15"/></svg>' +
|
|
'<span class="fb-name"></span>' +
|
|
'<button class="fb-cancel" title="Cancel">✕</button>' +
|
|
'</div><span class="meta">' + hhmm() + ' · 0%</span>'
|
|
b.querySelector('.fb-name').textContent = '📎 ' + name
|
|
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
|
|
return b
|
|
}
|
|
function setBubbleProgress(b, pct) {
|
|
const circ = 2 * Math.PI * 15
|
|
const fg = b.querySelector('.fb-fg')
|
|
if (fg) { fg.style.strokeDasharray = circ; fg.style.strokeDashoffset = circ * (1 - pct / 100) }
|
|
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + ' · ' + Math.round(pct) + '%'
|
|
}
|
|
function setBubbleDone(b, ok) {
|
|
const c = b.querySelector('.fb-cancel'); if (c) c.remove()
|
|
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + (ok ? ' · sent ✓' : ' · failed')
|
|
if (ok) setBubbleProgress(b, 100)
|
|
}
|
|
|
|
// uploadWithProgress streams the raw file to /upload via XHR (real upload-progress events) + supports CANCEL.
|
|
function uploadWithProgress(file, bubble) {
|
|
return new Promise((resolve) => {
|
|
const q = `token=${encodeURIComponent(token)}&name=${encodeURIComponent(file.name)}&type=${encodeURIComponent(file.type || 'application/octet-stream')}`
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('POST', `/baas/sessions/${encodeURIComponent(sessionId)}/upload?${q}`)
|
|
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
|
xhr.upload.onprogress = (e) => { if (e.lengthComputable) setBubbleProgress(bubble, (e.loaded / e.total) * 100) }
|
|
xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300)
|
|
xhr.onerror = () => resolve(false)
|
|
xhr.onabort = () => resolve('cancelled')
|
|
// the cancel (✕) button aborts the in-flight upload — like Signal's pause/cancel.
|
|
const cancelBtn = bubble.querySelector('.fb-cancel')
|
|
if (cancelBtn) cancelBtn.addEventListener('click', () => xhr.abort())
|
|
xhr.send(file)
|
|
})
|
|
}
|
|
|
|
// submitPayload posts ONE submission through the quantum tunnel; returns true on success.
|
|
async function submitPayload(payload) {
|
|
try {
|
|
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/submit?token=${encodeURIComponent(token)}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sealed: false, payload })
|
|
})
|
|
if (r.status === 429) { toast('Slow down'); return false }
|
|
return r.ok
|
|
} catch (e) { return false }
|
|
}
|
|
|
|
// ── composer wiring ──
|
|
function autoGrow() { const ta = el('input'); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px' }
|
|
el('joinBtn').addEventListener('click', join)
|
|
el('code').addEventListener('keydown', (e) => { if (e.key === 'Enter') join() })
|
|
el('sendBtn').addEventListener('click', send)
|
|
el('input').addEventListener('input', autoGrow)
|
|
el('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } })
|
|
el('attachBtn').addEventListener('click', () => el('fileInput').click())
|
|
el('fileInput').addEventListener('change', (e) => { if (e.target.files[0]) stageFile(e.target.files[0]) }) // STAGE, don't send
|
|
el('pvCancel').addEventListener('click', clearStaged)
|
|
|
|
// prefill code from ?c=
|
|
const qs = new URLSearchParams(location.search)
|
|
if (qs.get('c')) el('code').value = qs.get('c')
|