Files
AriskaandCursor c156efc882 mac commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:40:34 +07:00

363 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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.sender !== 'me') {
return '<span class="chat-msg-status received">Received</span>';
}
if (m.deliveredAt) {
return '<span class="chat-msg-status delivered">Sent · Received by contact</span>';
}
if (m.id) {
return '<span class="chat-msg-status sent">Sent</span>';
}
return '<span class="chat-msg-status pending">Sending…</span>';
}
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();
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) return;
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'
};
getThread(selectedPeer.peerId).push(msg);
if (msg.at > lastPollSince) lastPollSince = msg.at;
persistThread(networkId, selectedPeer.peerId);
input.value = '';
render();
} catch (_) {
} finally {
updateInputs();
}
};
updateInputs();
render();
},
unmount() {
stopPolling();
}
};
};