371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
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, '<').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
|
|
? ` · <span class="secure-chat-ping">${escapeHtml(pingText)}</span>`
|
|
: '';
|
|
header.innerHTML = `<strong>${escapeHtml(selectedPeer.displayName || selectedPeer.peerId)}</strong> <small>${escapeHtml(selectedPeer.peerId)}${net ? ' · ' + escapeHtml(net) : ''}${pingHtml}</small>`;
|
|
}
|
|
|
|
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 '<span class="chat-msg-status pending">Failed to send</span>';
|
|
}
|
|
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 = `
|
|
<div class="comm-layout comm-split-layout">
|
|
<aside class="comm-sidebar">
|
|
<div class="comm-sidebar-head">
|
|
<h2>Secure Chat</h2>
|
|
<p class="comm-sidebar-sub">CONTACTS</p>
|
|
<p class="comm-sidebar-core">BackOne core · same network as <code>backone-cli</code></p>
|
|
</div>
|
|
<div class="comm-sidebar-body">
|
|
<div id="secure-chat-peer-picker"></div>
|
|
</div>
|
|
</aside>
|
|
<div class="comm-main">
|
|
<div class="chat-thread-panel">
|
|
<div class="chat-thread-header" id="secure-chat-header">Select a contact</div>
|
|
<div id="secure-chat-thread" class="chat-messages"></div>
|
|
<div class="chat-compose">
|
|
<input id="secure-chat-input" type="text" placeholder="Type a secure message" disabled />
|
|
<div class="chat-compose-row">
|
|
<button type="button" id="secure-chat-ping" class="btn-secondary" disabled>Ping</button>
|
|
<button type="button" id="secure-chat-send" class="btn-primary" disabled>Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<p class="chat-empty">Choose a contact from the list</p>';
|
|
return;
|
|
}
|
|
const messages = getThread(selectedPeer.peerId);
|
|
if (!messages.length) {
|
|
thread.innerHTML = '<p class="chat-empty">No messages yet — start the conversation</p>';
|
|
return;
|
|
}
|
|
thread.innerHTML = messages.map(m => `
|
|
<div class="chat-message ${m.sender === 'me' ? 'chat-message-out' : 'chat-message-in'}">
|
|
<div class="chat-message-meta">
|
|
<span class="chat-message-sender">${escapeHtml(m.senderName || m.sender || 'me')}</span>
|
|
<span class="chat-message-time">${escapeHtml(formatTime(m.at))}</span>
|
|
${messageStatusHtml(m)}
|
|
</div>
|
|
${m.body ? `<div class="chat-message-body">${escapeHtml(m.body)}</div>` : ''}
|
|
</div>`).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();
|
|
}
|
|
};
|
|
};
|