window.BackOneAddons = window.BackOneAddons || {}; window.BackOneAddons['secure-chat'] = function secureChatAddon() { const HISTORY_PREFIX = 'backone_secure_chat_history_'; const POLL_MS = 5000; const messagesByPeer = {}; let selectedPeer = null; let root = null; let pollTimer = null; let lastPollSince = 0; let selectedPing = null; let pingInFlight = false; function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } function historyKey(networkId, peerId) { return `${HISTORY_PREFIX}${(networkId || '').toLowerCase()}_${(peerId || '').toLowerCase()}`; } function loadPersistedThread(networkId, peerId) { try { const raw = sessionStorage.getItem(historyKey(networkId, peerId)); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } function persistThread(networkId, peerId) { if (!networkId || !peerId) return; try { sessionStorage.setItem( historyKey(networkId, peerId), JSON.stringify(getThread(peerId)) ); } catch (_) {} } function getThread(peerId) { const key = (peerId || '').toLowerCase(); if (!messagesByPeer[key]) messagesByPeer[key] = []; return messagesByPeer[key]; } function formatTime(ts) { if (!ts) return ''; const d = new Date(ts); return d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }); } function networkLabel() { const n = BackOneNetworkSession.getActiveNetwork(); return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId(); } function renderHeader(pingHint) { const header = root?.querySelector('#secure-chat-header'); if (!header) return; const net = networkLabel(); if (!selectedPeer) { header.textContent = net ? `Select a contact on ${net}` : 'Join a network in the header bar'; return; } const pingText = pingHint || (selectedPing ? BackOnePeerDirectory.formatPingLabel(selectedPing) : ''); const pingHtml = pingText ? ` · ${escapeHtml(pingText)}` : ''; header.innerHTML = `${escapeHtml(selectedPeer.displayName || selectedPeer.peerId)} ${escapeHtml(selectedPeer.peerId)}${net ? ' · ' + escapeHtml(net) : ''}${pingHtml}`; } async function checkPing(peer) { if (!peer || pingInFlight) return; pingInFlight = true; selectedPing = null; renderHeader('Ping: checking…'); try { selectedPing = await BackOnePeerDirectory.pingPeer(peer); } catch (_) { selectedPing = { reachable: false, latencyMs: null, host: peer.ip || peer.peerId, source: 'error' }; } finally { pingInFlight = false; renderHeader(); } } function selfPeerId() { return (BackOneNetworkSession.getCoreStatus().address || '').toLowerCase(); } function selfName() { const addr = selfPeerId(); return addr ? addr.slice(0, 10) + '…' : 'me'; } function normalizeMessage(msg) { const senderId = (msg.senderId || msg.sender || '').toLowerCase(); const selfId = selfPeerId(); const isMe = senderId === selfId || senderId === 'local' || msg.sender === 'me'; const deliveredAt = msg.deliveredAt || null; return { id: msg.id || null, sender: isMe ? 'me' : senderId, senderName: msg.senderName || (isMe ? selfName() : senderId), body: msg.body || '', at: msg.at || msg.createdAt || Date.now(), deliveredAt, status: isMe ? (deliveredAt ? 'delivered' : 'sent') : 'received' }; } function messageStatusHtml(m) { if (m.status === 'failed') { return 'Failed to send'; } return ''; } function mergeMessages(peerId, incoming) { const thread = getThread(peerId); const byId = new Map(thread.filter(m => m.id).map(m => [m.id, m])); let changed = false; for (const raw of incoming) { const msg = normalizeMessage(raw); if (msg.id && byId.has(msg.id)) { const existing = byId.get(msg.id); if (msg.deliveredAt && !existing.deliveredAt) { existing.deliveredAt = msg.deliveredAt; existing.status = 'delivered'; changed = true; } continue; } thread.push(msg); if (msg.id) byId.set(msg.id, msg); changed = true; if (msg.at > lastPollSince) lastPollSince = msg.at; } if (changed) { thread.sort((a, b) => (a.at || 0) - (b.at || 0)); const networkId = BackOneNetworkSession.getNetworkId(); persistThread(networkId, peerId); } return changed; } async function fetchThread(networkId, peerId) { const params = new URLSearchParams({ targetId: peerId }); const readerId = selfPeerId(); if (readerId) params.set('readerId', readerId); if (lastPollSince > 0) params.set('since', String(lastPollSince)); const res = await BackOneAuth.fetchWithAuth( `/controller/comms/network/${encodeURIComponent(networkId)}/secure-chat?${params}` ); if (!res.ok) return false; const data = await res.json(); if (!Array.isArray(data)) return false; return mergeMessages(peerId, data); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } function startPolling(render) { stopPolling(); pollTimer = setInterval(async () => { const networkId = BackOneNetworkSession.getNetworkId(); if (!networkId || !selectedPeer) return; try { const changed = await fetchThread(networkId, selectedPeer.peerId); if (changed) render(); } catch (_) {} }, POLL_MS); } return { mount(container) { root = container; container.innerHTML = `
Select a contact
`; const thread = container.querySelector('#secure-chat-thread'); const input = container.querySelector('#secure-chat-input'); const sendBtn = container.querySelector('#secure-chat-send'); const pingBtn = container.querySelector('#secure-chat-ping'); const render = () => { if (!selectedPeer) { thread.innerHTML = '

Choose a contact from the list

'; return; } const messages = getThread(selectedPeer.peerId); if (!messages.length) { thread.innerHTML = '

No messages yet — start the conversation

'; return; } thread.innerHTML = messages.map(m => `
${escapeHtml(m.senderName || m.sender || 'me')} ${escapeHtml(formatTime(m.at))} ${messageStatusHtml(m)}
${m.body ? `
${escapeHtml(m.body)}
` : ''}
`).join(''); thread.scrollTop = thread.scrollHeight; }; function hydrateThread(peerId) { const networkId = BackOneNetworkSession.getNetworkId(); if (!networkId || !peerId) return; const key = peerId.toLowerCase(); messagesByPeer[key] = loadPersistedThread(networkId, peerId); lastPollSince = messagesByPeer[key].reduce((max, m) => Math.max(max, m.at || 0), 0); } async function openThread(peer) { selectedPeer = peer; selectedPing = null; hydrateThread(peer.peerId); updateInputs(); render(); checkPing(peer); const networkId = BackOneNetworkSession.getNetworkId(); if (networkId) { try { const changed = await fetchThread(networkId, peer.peerId); if (changed) render(); } catch (_) {} startPolling(render); } } function updateInputs() { const networkId = BackOneNetworkSession.getNetworkId(); const ok = !!networkId && !!selectedPeer; input.disabled = !ok; sendBtn.disabled = !ok; if (pingBtn) pingBtn.disabled = !ok || pingInFlight; renderHeader(); } BackOnePeerPicker(container.querySelector('#secure-chat-peer-picker'), { layout: 'sidebar', showActionButton: false, onSelect(peer) { openThread(peer); }, onNetworkChange(_network, _peers, meta) { if (meta && meta.networkChanged === false) return; stopPolling(); selectedPeer = null; selectedPing = null; lastPollSince = 0; updateInputs(); render(); } }); BackOneNetworkSession.subscribe(() => { updateInputs(); render(); }); if (pingBtn) { pingBtn.onclick = () => { if (selectedPeer) checkPing(selectedPeer); }; } sendBtn.onclick = async () => { const networkId = BackOneNetworkSession.getNetworkId(); if (!selectedPeer || !networkId) return; const body = input.value.trim(); if (!body) return; const senderId = selfPeerId() || 'local'; const name = selfName(); const thread = getThread(selectedPeer.peerId); sendBtn.disabled = true; try { const res = await BackOneAuth.fetchWithAuth( `/controller/comms/network/${encodeURIComponent(networkId)}/secure-chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, targetId: selectedPeer.peerId, networkId, senderId }) } ); if (!res.ok) throw new Error(`secure chat send failed: ${res.status}`); const data = await res.json(); const now = data.createdAt || Date.now(); const msg = { id: data.id || null, sender: 'me', senderName: name, body, at: now, deliveredAt: null, status: 'sent' }; thread.push(msg); if (msg.at > lastPollSince) lastPollSince = msg.at; persistThread(networkId, selectedPeer.peerId); input.value = ''; render(); } catch (_) { const now = Date.now(); thread.push({ id: null, sender: 'me', senderName: name, body, at: now, deliveredAt: null, status: 'failed' }); if (now > lastPollSince) lastPollSince = now; persistThread(networkId, selectedPeer.peerId); render(); } finally { updateInputs(); } }; updateInputs(); render(); }, unmount() { stopPolling(); } }; };