No central server, user-owned data, reverse-chronological feed. Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2702 lines
112 KiB
JavaScript
2702 lines
112 KiB
JavaScript
// Tauri v2 with withGlobalTauri: true injects window.__TAURI__
|
|
const { invoke } = window.__TAURI__.core;
|
|
|
|
// --- DOM refs ---
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const setupOverlay = $('#setup-overlay');
|
|
const setupName = $('#setup-name');
|
|
const setupBtn = $('#setup-btn');
|
|
const nodeIdEl = $('#node-id');
|
|
const copyBtn = $('#copy-connect');
|
|
const postContent = $('#post-content');
|
|
const postBtn = $('#post-btn');
|
|
const charCount = $('#char-count');
|
|
const feedList = $('#feed-list');
|
|
const myPostsList = $('#my-posts-list');
|
|
const conversationsList = $('#conversations-list');
|
|
const messageRequestsList = $('#message-requests-list');
|
|
const messageRequestsSection = $('#message-requests-section');
|
|
const connectInput = $('#connect-input');
|
|
const connectBtn = $('#connect-btn');
|
|
const connectStatus = $('#connect-status');
|
|
let peersList = null; // created dynamically inside diagnostics popover
|
|
const followsList = $('#follows-list');
|
|
// suggestedList removed — Suggested Peers section removed from UI
|
|
const syncBtn = $('#sync-btn');
|
|
const exportKeyBtn = $('#export-key-btn');
|
|
const visibilitySelect = $('#visibility-select');
|
|
const circleSelect = $('#circle-select');
|
|
const circleNameInput = $('#circle-name-input');
|
|
const createCircleBtn = $('#create-circle-btn');
|
|
const circlesList = $('#circles-list');
|
|
const statPosts = $('#stat-posts');
|
|
const statPeers = $('#stat-peers');
|
|
const statFollows = $('#stat-follows');
|
|
const toastEl = $('#toast');
|
|
const profileNameInput = $('#profile-name');
|
|
const profileBioInput = $('#profile-bio');
|
|
const saveProfileBtn = $('#save-profile-btn');
|
|
const redundancyPanel = $('#redundancy-panel');
|
|
const dmRecipientSelect = $('#dm-recipient-select');
|
|
const dmContent = $('#dm-content');
|
|
const dmSendBtn = $('#dm-send-btn');
|
|
const anchorsList = $('#anchors-list');
|
|
const anchorAddSelect = $('#anchor-add-select');
|
|
const anchorAddBtn = $('#anchor-add-btn');
|
|
const attachBtn = $('#attach-btn');
|
|
const fileInput = $('#file-input');
|
|
const attachmentPreview = $('#attachment-preview');
|
|
const audiencePendingList = $('#audience-pending-list');
|
|
const audienceApprovedList = $('#audience-approved-list');
|
|
let connectionsList = null; // created dynamically inside diagnostics popover
|
|
let networkSummaryEl = null; // created dynamically inside diagnostics popover
|
|
const resetDataBtn = $('#reset-data-btn');
|
|
|
|
// --- State ---
|
|
let currentTab = 'feed';
|
|
let connectString = '';
|
|
let myNodeId = '';
|
|
const POST_MAX_CHARS = 500;
|
|
let selectedFiles = []; // { data: ArrayBuffer, mime: string, name: string }
|
|
let diagnosticsInterval = null;
|
|
let lastDiagUpdate = null;
|
|
// Cache fingerprints to avoid destructive reloads when data hasn't changed
|
|
let _feedFingerprint = '';
|
|
let _myPostsFingerprint = '';
|
|
let _messagesFingerprint = '';
|
|
let _lastMsgTimestamp = 0; // Track newest message timestamp for tiered polling
|
|
let _peopleFingerprint = '';
|
|
|
|
// --- Identicon generator ---
|
|
// Generates a deterministic 5x5 mirrored SVG identicon from a hex node_id.
|
|
function generateIdenticon(hexId, size = 20) {
|
|
const bytes = [];
|
|
const hex = hexId.replace(/[^0-9a-fA-F]/g, '');
|
|
for (let i = 0; i < 16 && i * 2 + 1 < hex.length; i++) {
|
|
bytes.push(parseInt(hex.substring(i * 2, i * 2 + 2), 16));
|
|
}
|
|
while (bytes.length < 16) bytes.push(0);
|
|
|
|
const hue = ((bytes[0] << 8) | bytes[1]) % 360;
|
|
const sat = 55 + (bytes[2] % 25);
|
|
const lit = 55 + (bytes[3] % 20);
|
|
const fg = `hsl(${hue}, ${sat}%, ${lit}%)`;
|
|
const bg = `hsl(${hue}, ${sat}%, ${lit * 0.25}%)`;
|
|
|
|
const cells = [];
|
|
for (let row = 0; row < 5; row++) {
|
|
for (let col = 0; col < 3; col++) {
|
|
const byteIdx = 4 + row * 3 + col;
|
|
const on = byteIdx < bytes.length ? (bytes[byteIdx] & 1) === 1 : false;
|
|
if (on) {
|
|
const cellSize = size / 5;
|
|
cells.push(`<rect x="${col * cellSize}" y="${row * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`);
|
|
const mirrorCol = 4 - col;
|
|
if (mirrorCol !== col) {
|
|
cells.push(`<rect x="${mirrorCol * cellSize}" y="${row * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return `<svg class="identicon" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bg}" rx="2"/>${cells.join('')}</svg>`;
|
|
}
|
|
|
|
// --- QR Code SVG generator ---
|
|
// Minimal QR code encoder for alphanumeric data (connect strings).
|
|
// Supports versions 1-6, error correction level L.
|
|
function generateQRCodeSVG(text, pixelSize) {
|
|
// GF(256) arithmetic for Reed-Solomon
|
|
const GF_EXP = new Uint8Array(512);
|
|
const GF_LOG = new Uint8Array(256);
|
|
{
|
|
let v = 1;
|
|
for (let i = 0; i < 255; i++) {
|
|
GF_EXP[i] = v;
|
|
GF_LOG[v] = i;
|
|
v <<= 1;
|
|
if (v >= 256) v ^= 0x11d;
|
|
}
|
|
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
|
|
}
|
|
function gfMul(a, b) { return a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]; }
|
|
|
|
function rsGenPoly(nsym) {
|
|
let g = [1];
|
|
for (let i = 0; i < nsym; i++) {
|
|
const ng = new Array(g.length + 1).fill(0);
|
|
for (let j = 0; j < g.length; j++) {
|
|
ng[j] ^= g[j];
|
|
ng[j + 1] ^= gfMul(g[j], GF_EXP[i]);
|
|
}
|
|
g = ng;
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function rsEncode(data, nsym) {
|
|
const gen = rsGenPoly(nsym);
|
|
const out = new Uint8Array(data.length + nsym);
|
|
out.set(data);
|
|
for (let i = 0; i < data.length; i++) {
|
|
const coef = out[i];
|
|
if (coef !== 0) {
|
|
for (let j = 0; j < gen.length; j++) {
|
|
out[i + j] ^= gfMul(gen[j], coef);
|
|
}
|
|
}
|
|
}
|
|
return out.slice(data.length);
|
|
}
|
|
|
|
// Alphanumeric encoding
|
|
const ALNUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
|
|
function encodeAlphanumeric(str) {
|
|
const bits = [];
|
|
function pushBits(val, len) { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); }
|
|
const upper = str.toUpperCase();
|
|
// Determine version: data capacity for alphanumeric, EC level L
|
|
// version: [totalCodewords, ecCodewords, dataCapacityAlphanumeric]
|
|
const versions = [
|
|
null,
|
|
[26, 7, 25], // v1
|
|
[44, 10, 47], // v2
|
|
[70, 15, 77], // v3
|
|
[100, 20, 114], // v4
|
|
[134, 26, 154], // v5
|
|
[172, 36, 195], // v6
|
|
];
|
|
let ver = 0;
|
|
for (let v = 1; v <= 6; v++) {
|
|
if (upper.length <= versions[v][2]) { ver = v; break; }
|
|
}
|
|
if (ver === 0) throw new Error('Text too long for QR v1-6');
|
|
|
|
const charCountBits = ver <= 9 ? 9 : 13;
|
|
pushBits(0b0010, 4); // mode: alphanumeric
|
|
pushBits(upper.length, charCountBits);
|
|
|
|
for (let i = 0; i < upper.length; i += 2) {
|
|
const a = ALNUM.indexOf(upper[i]);
|
|
if (i + 1 < upper.length) {
|
|
const b = ALNUM.indexOf(upper[i + 1]);
|
|
pushBits(a * 45 + b, 11);
|
|
} else {
|
|
pushBits(a, 6);
|
|
}
|
|
}
|
|
|
|
const [totalCW, ecCW] = versions[ver];
|
|
const dataCW = totalCW - ecCW;
|
|
const totalDataBits = dataCW * 8;
|
|
|
|
// Terminator
|
|
const remaining = totalDataBits - bits.length;
|
|
const termLen = Math.min(4, remaining);
|
|
for (let i = 0; i < termLen; i++) bits.push(0);
|
|
// Byte-align
|
|
while (bits.length % 8 !== 0) bits.push(0);
|
|
// Pad bytes
|
|
const pads = [0xEC, 0x11];
|
|
let pi = 0;
|
|
while (bits.length < totalDataBits) {
|
|
for (let b = 7; b >= 0; b--) bits.push((pads[pi] >> b) & 1);
|
|
pi = (pi + 1) % 2;
|
|
}
|
|
|
|
// Convert to bytes
|
|
const dataBytes = new Uint8Array(dataCW);
|
|
for (let i = 0; i < dataCW; i++) {
|
|
let v2 = 0;
|
|
for (let b = 0; b < 8; b++) v2 = (v2 << 1) | (bits[i * 8 + b] || 0);
|
|
dataBytes[i] = v2;
|
|
}
|
|
|
|
const ecBytes = rsEncode(dataBytes, ecCW);
|
|
return { ver, dataBytes, ecBytes, totalCW, ecCW };
|
|
}
|
|
|
|
const { ver, dataBytes, ecBytes } = encodeAlphanumeric(text);
|
|
const size = 17 + ver * 4;
|
|
const grid = Array.from({ length: size }, () => new Uint8Array(size));
|
|
const reserved = Array.from({ length: size }, () => new Uint8Array(size));
|
|
|
|
function setModule(r, c, val) {
|
|
if (r >= 0 && r < size && c >= 0 && c < size) {
|
|
grid[r][c] = val ? 1 : 0;
|
|
reserved[r][c] = 1;
|
|
}
|
|
}
|
|
|
|
// Finder patterns
|
|
function drawFinder(row, col) {
|
|
for (let dr = -1; dr <= 7; dr++) {
|
|
for (let dc = -1; dc <= 7; dc++) {
|
|
const r = row + dr, c = col + dc;
|
|
if (r < 0 || r >= size || c < 0 || c >= size) continue;
|
|
const inOuter = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
|
const inInner = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4;
|
|
const onBorder = dr === 0 || dr === 6 || dc === 0 || dc === 6;
|
|
const val = inInner || (inOuter && onBorder);
|
|
setModule(r, c, val);
|
|
}
|
|
}
|
|
}
|
|
drawFinder(0, 0);
|
|
drawFinder(0, size - 7);
|
|
drawFinder(size - 7, 0);
|
|
|
|
// Timing patterns
|
|
for (let i = 8; i < size - 8; i++) {
|
|
setModule(6, i, i % 2 === 0);
|
|
setModule(i, 6, i % 2 === 0);
|
|
}
|
|
|
|
// Dark module
|
|
setModule(size - 8, 8, 1);
|
|
|
|
// Reserve format info areas
|
|
for (let i = 0; i < 8; i++) {
|
|
if (!reserved[8][i]) { reserved[8][i] = 1; }
|
|
if (!reserved[i][8]) { reserved[i][8] = 1; }
|
|
if (!reserved[8][size - 1 - i]) { reserved[8][size - 1 - i] = 1; }
|
|
if (!reserved[size - 1 - i][8]) { reserved[size - 1 - i][8] = 1; }
|
|
}
|
|
reserved[8][8] = 1;
|
|
|
|
// Alignment pattern for v2+
|
|
if (ver >= 2) {
|
|
const alignPos = [null, null, [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]][ver];
|
|
for (const ar of alignPos) {
|
|
for (const ac of alignPos) {
|
|
if (reserved[ar][ac]) continue;
|
|
for (let dr = -2; dr <= 2; dr++) {
|
|
for (let dc = -2; dc <= 2; dc++) {
|
|
const val = Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0);
|
|
setModule(ar + dr, ac + dc, val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Place data bits
|
|
const allBytes = new Uint8Array(dataBytes.length + ecBytes.length);
|
|
allBytes.set(dataBytes);
|
|
allBytes.set(ecBytes, dataBytes.length);
|
|
|
|
const dataBits = [];
|
|
for (const b of allBytes) {
|
|
for (let i = 7; i >= 0; i--) dataBits.push((b >> i) & 1);
|
|
}
|
|
|
|
let bitIdx = 0;
|
|
let upward = true;
|
|
for (let col = size - 1; col >= 0; col -= 2) {
|
|
if (col === 6) col = 5; // skip timing column
|
|
const rows = upward ? Array.from({ length: size }, (_, i) => size - 1 - i) : Array.from({ length: size }, (_, i) => i);
|
|
for (const row of rows) {
|
|
for (const dc of [0, -1]) {
|
|
const c = col + dc;
|
|
if (c < 0 || c >= size) continue;
|
|
if (reserved[row][c]) continue;
|
|
grid[row][c] = bitIdx < dataBits.length ? dataBits[bitIdx] : 0;
|
|
bitIdx++;
|
|
}
|
|
}
|
|
upward = !upward;
|
|
}
|
|
|
|
// Apply mask 0 (checkerboard: (row + col) % 2 === 0)
|
|
for (let r = 0; r < size; r++) {
|
|
for (let c = 0; c < size; c++) {
|
|
if (!reserved[r][c] && (r + c) % 2 === 0) {
|
|
grid[r][c] ^= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format info for EC level L (01), mask 0 (000) → format bits = 0b01000
|
|
// Pre-computed with BCH: 0x77c0 → bits: 111011111000010
|
|
const formatBits = [1,1,1,0,1,1,1,1,1,0,0,0,0,1,0];
|
|
// Place format info
|
|
const formatPositions1 = [[0,8],[1,8],[2,8],[3,8],[4,8],[5,8],[7,8],[8,8],[8,7],[8,5],[8,4],[8,3],[8,2],[8,1],[8,0]];
|
|
const formatPositions2 = [[8,size-1],[8,size-2],[8,size-3],[8,size-4],[8,size-5],[8,size-6],[8,size-7],[size-7,8],[size-6,8],[size-5,8],[size-4,8],[size-3,8],[size-2,8],[size-1,8]];
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
const bit = formatBits[i];
|
|
const [r1, c1] = formatPositions1[i];
|
|
grid[r1][c1] = bit;
|
|
if (i < formatPositions2.length) {
|
|
const [r2, c2] = formatPositions2[i];
|
|
grid[r2][c2] = bit;
|
|
}
|
|
}
|
|
|
|
// Render SVG
|
|
const moduleSize = Math.max(1, Math.floor(pixelSize / (size + 8))); // +8 for quiet zone
|
|
const totalSize = (size + 8) * moduleSize;
|
|
let rects = '';
|
|
for (let r = 0; r < size; r++) {
|
|
for (let c = 0; c < size; c++) {
|
|
if (grid[r][c]) {
|
|
rects += `<rect x="${(c + 4) * moduleSize}" y="${(r + 4) * moduleSize}" width="${moduleSize}" height="${moduleSize}"/>`;
|
|
}
|
|
}
|
|
}
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalSize}" height="${totalSize}" viewBox="0 0 ${totalSize} ${totalSize}">
|
|
<rect width="${totalSize}" height="${totalSize}" fill="#fff" rx="4"/>
|
|
<g fill="#000">${rects}</g>
|
|
</svg>`;
|
|
}
|
|
|
|
// --- Helpers ---
|
|
function toast(msg) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.remove('hidden');
|
|
setTimeout(() => toastEl.classList.add('hidden'), 3000);
|
|
}
|
|
|
|
// --- Notifications ---
|
|
let _notifiedMessages = new Set();
|
|
async function maybeNotify(title, body, tag) {
|
|
if (!('Notification' in window)) return;
|
|
if (Notification.permission === 'default') {
|
|
await Notification.requestPermission();
|
|
}
|
|
if (Notification.permission === 'granted') {
|
|
new Notification(title, { body, tag, silent: false });
|
|
}
|
|
}
|
|
|
|
// --- Popover helpers ---
|
|
let popoverOnClose = null;
|
|
function openPopover(title, html, opts = {}) {
|
|
const overlay = $('#popover-overlay');
|
|
$('#popover-title').textContent = title;
|
|
$('#popover-body').innerHTML = html;
|
|
overlay.classList.remove('hidden');
|
|
if (opts.onOpen) opts.onOpen();
|
|
popoverOnClose = opts.onClose || null;
|
|
}
|
|
function closePopover() {
|
|
const overlay = $('#popover-overlay');
|
|
overlay.classList.add('hidden');
|
|
$('#popover-body').innerHTML = '';
|
|
if (diagnosticsInterval) { clearInterval(diagnosticsInterval); diagnosticsInterval = null; }
|
|
if (activityInterval) { clearInterval(activityInterval); activityInterval = null; }
|
|
// Clear dynamic refs from popover content
|
|
networkSummaryEl = null; connectionsList = null; peersList = null;
|
|
if (popoverOnClose) { popoverOnClose(); popoverOnClose = null; }
|
|
}
|
|
$('#popover-close-btn').addEventListener('click', closePopover);
|
|
$('#popover-overlay').addEventListener('click', (e) => {
|
|
if (e.target === $('#popover-overlay')) closePopover();
|
|
});
|
|
|
|
function relativeTime(timestampMs) {
|
|
const now = Date.now();
|
|
const diff = now - timestampMs;
|
|
const secs = Math.floor(diff / 1000);
|
|
if (secs < 60) return 'just now';
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return 'yesterday';
|
|
if (days < 7) return `${days}d ago`;
|
|
return new Date(timestampMs).toLocaleDateString();
|
|
}
|
|
|
|
function peerLabel(nodeId, displayName) {
|
|
if (displayName) return displayName;
|
|
return nodeId.substring(0, 12) + '...';
|
|
}
|
|
|
|
function renderPost(post, index) {
|
|
const authorShort = post.author.substring(0, 12);
|
|
const authorName = post.authorName || authorShort;
|
|
const authorClass = post.isMe ? 'author-me' : '';
|
|
const meTag = post.isMe ? ' (you)' : '';
|
|
const timeStr = relativeTime(post.timestampMs);
|
|
const icon = generateIdenticon(post.author, 22);
|
|
const delay = Math.min(index * 0.04, 0.6);
|
|
|
|
let visBadge = '';
|
|
if (post.visibility === 'encrypted-for-me') {
|
|
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
|
} else if (post.visibility === 'encrypted') {
|
|
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
|
}
|
|
|
|
let displayContent;
|
|
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
|
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
|
} else if (post.decryptedContent) {
|
|
displayContent = escapeHtml(post.decryptedContent);
|
|
} else {
|
|
displayContent = escapeHtml(post.content);
|
|
}
|
|
|
|
const deleteBtn = post.isMe
|
|
? `<button class="delete-post-btn" data-post-id="${post.id}" title="Delete post">x</button>`
|
|
: '';
|
|
|
|
let attachmentsHtml = '';
|
|
if (post.attachments && post.attachments.length > 0) {
|
|
const imgs = post.attachments.filter(a => a.mimeType.startsWith('image/'));
|
|
const vids = post.attachments.filter(a => a.mimeType.startsWith('video/'));
|
|
const auds = post.attachments.filter(a => a.mimeType.startsWith('audio/'));
|
|
const others = post.attachments.filter(a => !a.mimeType.startsWith('image/') && !a.mimeType.startsWith('video/') && !a.mimeType.startsWith('audio/'));
|
|
let inner = '';
|
|
for (const a of imgs) {
|
|
inner += `<img class="post-image" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" alt="attachment" />`;
|
|
}
|
|
for (const a of vids) {
|
|
inner += `<div class="video-wrap">
|
|
<video class="post-video" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" controls preload="none"></video>
|
|
<div class="video-controls"><button class="video-download" data-cid="${a.cid}" data-mime="${escapeHtml(a.mimeType)}" title="Download video">Download</button></div>
|
|
</div>`;
|
|
}
|
|
for (const a of auds) {
|
|
inner += `<audio class="post-audio" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" controls preload="none"></audio>`;
|
|
}
|
|
for (const a of others) {
|
|
const sizeKb = Math.round(a.sizeBytes / 1024);
|
|
const ext = a.mimeType.split('/').pop() || 'file';
|
|
inner += `<button class="post-file file-download" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" data-ext="${escapeHtml(ext)}" title="Download">${escapeHtml(ext.toUpperCase())} (${sizeKb} KB)</button>`;
|
|
}
|
|
attachmentsHtml = `<div class="post-attachments">${inner}</div>`;
|
|
}
|
|
|
|
const authorLink = post.isMe
|
|
? `<span class="${authorClass}">${escapeHtml(authorName)}${meTag}</span>`
|
|
: `<a class="post-author-link" data-node-id="${post.author}" title="View in People tab">${escapeHtml(authorName)}</a>`;
|
|
|
|
// Engagement bar: reactions (top 5 + total) + comments
|
|
let reactionsHtml = '';
|
|
if (post.reactionCounts && post.reactionCounts.length > 0) {
|
|
const sorted = [...post.reactionCounts].sort((a, b) => b.count - a.count);
|
|
const top5 = sorted.slice(0, 5);
|
|
const totalResponses = sorted.reduce((sum, r) => sum + r.count, 0);
|
|
const pills = top5.map(r =>
|
|
`<button class="reaction-pill${r.reactedByMe ? ' reacted' : ''}" data-post-id="${post.id}" data-emoji="${escapeHtml(r.emoji)}">${r.emoji} ${r.count}</button>`
|
|
).join('');
|
|
const summary = totalResponses > 0 ? `<span class="reaction-summary">${totalResponses} response${totalResponses !== 1 ? 's' : ''}</span>` : '';
|
|
reactionsHtml = pills + summary;
|
|
}
|
|
|
|
const commentCount = post.commentCount || 0;
|
|
const shareBtn = post.visibility === 'public'
|
|
? `<button class="share-btn" data-post-id="${post.id}" title="Copy share link">Share</button>`
|
|
: '';
|
|
const engagementBar = `<div class="engagement-bar">
|
|
<div class="reaction-pills">${reactionsHtml}<button class="react-btn" data-post-id="${post.id}" title="React">\u263A</button></div>
|
|
<div class="engagement-right"><button class="comment-toggle-btn" data-post-id="${post.id}">Comment${commentCount > 0 ? ` (${commentCount})` : ''}</button>${shareBtn}</div>
|
|
</div>`;
|
|
|
|
return `<div class="post" style="animation-delay: ${delay}s" data-post-id="${post.id}">
|
|
<div class="post-meta">
|
|
<span class="post-author">${icon}${authorLink}${visBadge}</span>
|
|
<span class="post-time" title="${new Date(post.timestampMs).toLocaleString()}">${timeStr}</span>
|
|
${deleteBtn}
|
|
</div>
|
|
<div class="post-content">${displayContent}</div>
|
|
${attachmentsHtml}
|
|
${engagementBar}
|
|
<div class="comment-thread hidden" id="comments-${post.id}"></div>
|
|
<div class="post-id">${post.id.substring(0, 16)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Render a message post (similar to renderPost but with follow action for requests)
|
|
function renderMessage(post, index, showFollowBtn) {
|
|
const html = renderPost(post, index);
|
|
if (!showFollowBtn) return html;
|
|
// Insert a follow button after the post
|
|
return html + `<div class="msg-request-action">
|
|
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${post.author}">Follow to accept</button>
|
|
</div>`;
|
|
}
|
|
|
|
function renderEmptyState(message, hint) {
|
|
return `<div class="empty-state">
|
|
<div class="empty-state-icon"></div>
|
|
<p class="empty-state-msg">${escapeHtml(message)}</p>
|
|
${hint ? `<p class="empty-state-hint">${escapeHtml(hint)}</p>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function renderLoading() {
|
|
return '<div class="loading-state"><div class="loading-dots"><span></span><span></span><span></span></div></div>';
|
|
}
|
|
|
|
function updateTabBadge(tabName, count) {
|
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
|
if (!tab) return;
|
|
let badge = tab.querySelector('.tab-badge');
|
|
if (count > 0) {
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'tab-badge';
|
|
tab.appendChild(badge);
|
|
}
|
|
badge.textContent = count;
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
|
|
// --- Compose auto-grow ---
|
|
function autoGrow(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.max(el.scrollHeight, 60) + 'px';
|
|
}
|
|
|
|
function updateCharCount() {
|
|
const len = postContent.value.length;
|
|
charCount.textContent = `${len}/${POST_MAX_CHARS}`;
|
|
charCount.classList.toggle('char-warn', len > POST_MAX_CHARS * 0.9);
|
|
charCount.classList.toggle('char-over', len >= POST_MAX_CHARS);
|
|
}
|
|
|
|
// --- Data loaders ---
|
|
async function loadNodeInfo() {
|
|
try {
|
|
const info = await invoke('get_node_info');
|
|
myNodeId = info.nodeId;
|
|
const label = info.displayName || info.nodeId.substring(0, 16) + '...';
|
|
const icon = generateIdenticon(info.nodeId, 18);
|
|
nodeIdEl.innerHTML = icon + ' <span>' + escapeHtml(label) + '</span>';
|
|
nodeIdEl.title = info.nodeId;
|
|
connectString = info.connectString;
|
|
// Show connect string and QR code
|
|
const csDisplay = $('#connect-string-display');
|
|
const qrContainer = $('#qr-code');
|
|
if (csDisplay && connectString) {
|
|
csDisplay.textContent = connectString;
|
|
}
|
|
if (qrContainer && connectString) {
|
|
try {
|
|
qrContainer.innerHTML = generateQRCodeSVG(connectString, 200);
|
|
} catch (qrErr) {
|
|
qrContainer.innerHTML = '<span class="empty-hint">QR code unavailable</span>';
|
|
}
|
|
}
|
|
// Pre-fill profile editor with current values
|
|
if (info.displayName && !profileNameInput.dataset.touched) {
|
|
profileNameInput.value = info.displayName;
|
|
}
|
|
return info;
|
|
} catch (e) {
|
|
nodeIdEl.textContent = 'error';
|
|
console.error('loadNodeInfo:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const s = await invoke('get_stats');
|
|
statPosts.textContent = `${s.postCount} posts`;
|
|
statFollows.textContent = `${s.followCount} following`;
|
|
// Show people count as follows + audience, with "+" if more discoverable peers exist
|
|
const peopleCount = s.followCount;
|
|
const hasMore = s.peerCount > s.followCount;
|
|
statPeers.textContent = `${peopleCount}${hasMore ? '+' : ''} people`;
|
|
updateTabBadge('people', peopleCount);
|
|
} catch (e) {
|
|
console.error('loadStats:', e);
|
|
}
|
|
}
|
|
|
|
async function loadFeed(force) {
|
|
try {
|
|
const allPosts = await invoke('get_feed');
|
|
const posts = allPosts.filter(p => p.visibility !== 'encrypted-for-me');
|
|
// Fingerprint: post IDs + reaction counts + comment counts
|
|
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
|
|
if (!force && fp === _feedFingerprint) return;
|
|
_feedFingerprint = fp;
|
|
// Preserve expanded comment threads
|
|
const expandedComments = new Set();
|
|
feedList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => {
|
|
const postEl = el.closest('.post');
|
|
if (postEl) expandedComments.add(postEl.dataset.postId);
|
|
});
|
|
if (posts.length === 0) {
|
|
feedList.innerHTML = renderEmptyState(
|
|
'Your feed is empty',
|
|
'Follow peers on the People tab to see their posts here.'
|
|
);
|
|
} else {
|
|
feedList.innerHTML = posts.map(renderPost).join('');
|
|
loadPostMedia(feedList);
|
|
// Restore expanded comment threads
|
|
for (const postId of expandedComments) {
|
|
const thread = feedList.querySelector(`#comments-${postId}`);
|
|
if (thread) {
|
|
thread.classList.remove('hidden');
|
|
loadCommentThread(postId, thread);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
feedList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadMyPosts(force) {
|
|
try {
|
|
const posts = await invoke('get_all_posts');
|
|
const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me');
|
|
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
|
|
if (!force && fp === _myPostsFingerprint) return;
|
|
_myPostsFingerprint = fp;
|
|
const expandedComments = new Set();
|
|
myPostsList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => {
|
|
const postEl = el.closest('.post');
|
|
if (postEl) expandedComments.add(postEl.dataset.postId);
|
|
});
|
|
if (mine.length === 0) {
|
|
myPostsList.innerHTML = renderEmptyState(
|
|
'No posts yet',
|
|
'Write your first post above!'
|
|
);
|
|
} else {
|
|
myPostsList.innerHTML = mine.map(renderPost).join('');
|
|
loadPostMedia(myPostsList);
|
|
for (const postId of expandedComments) {
|
|
const thread = myPostsList.querySelector(`#comments-${postId}`);
|
|
if (thread) {
|
|
thread.classList.remove('hidden');
|
|
loadCommentThread(postId, thread);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
myPostsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadMessages(force) {
|
|
try {
|
|
const [posts, follows] = await Promise.all([
|
|
invoke('get_all_posts'),
|
|
invoke('list_follows'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
|
|
// Collect DMs: received encrypted-for-me OR my sent encrypted with recipients
|
|
const dms = posts.filter(p => {
|
|
if (!p.isMe && p.visibility === 'encrypted-for-me') return true;
|
|
if (p.isMe && p.recipients && p.recipients.length > 0) return true;
|
|
return false;
|
|
});
|
|
|
|
// Separate message requests (from non-followed) vs conversations (from followed or sent by me)
|
|
const requests = dms.filter(p => !p.isMe && !followSet.has(p.author));
|
|
const convMessages = dms.filter(p => p.isMe || followSet.has(p.author));
|
|
|
|
// Group conversation messages by partner
|
|
const threads = new Map(); // partnerNodeId → { posts: [], partnerName: string|null }
|
|
for (const p of convMessages) {
|
|
let partner;
|
|
if (p.isMe) {
|
|
// Sent DM — partner is first recipient that isn't me
|
|
partner = p.recipients.find(r => r !== myNodeId) || p.recipients[0];
|
|
} else {
|
|
partner = p.author;
|
|
}
|
|
if (!partner) continue;
|
|
if (!threads.has(partner)) {
|
|
threads.set(partner, { posts: [], partnerName: null });
|
|
}
|
|
threads.get(partner).posts.push(p);
|
|
}
|
|
|
|
// Resolve partner names + sort threads by most recent message
|
|
for (const [partnerId, thread] of threads) {
|
|
thread.posts.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
// Get partner name from existing post data
|
|
const partnerPost = thread.posts.find(p => p.author === partnerId);
|
|
thread.partnerName = partnerPost ? partnerPost.authorName : null;
|
|
if (!thread.partnerName) {
|
|
const follow = follows.find(f => f.nodeId === partnerId);
|
|
if (follow) thread.partnerName = follow.displayName;
|
|
}
|
|
}
|
|
|
|
const sortedThreads = [...threads.entries()].sort((a, b) => {
|
|
const aLast = a[1].posts[a[1].posts.length - 1].timestampMs;
|
|
const bLast = b[1].posts[b[1].posts.length - 1].timestampMs;
|
|
return bLast - aLast;
|
|
});
|
|
|
|
// Track newest message timestamp for tiered polling
|
|
if (sortedThreads.length > 0) {
|
|
_lastMsgTimestamp = sortedThreads[0][1].posts[sortedThreads[0][1].posts.length - 1].timestampMs;
|
|
}
|
|
|
|
// Fingerprint: partner IDs + message counts + last timestamp
|
|
const fp = sortedThreads.map(([pid, t]) => `${pid}:${t.posts.length}:${t.posts[t.posts.length-1].timestampMs}`).join('|')
|
|
+ '|req:' + requests.map(p => p.id).join(',');
|
|
if (!force && fp === _messagesFingerprint) return;
|
|
_messagesFingerprint = fp;
|
|
|
|
// Notify on new incoming messages
|
|
try {
|
|
const notifMsg = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
|
if (notifMsg !== 'off') {
|
|
for (const [partnerId, thread] of sortedThreads) {
|
|
for (const p of thread.posts) {
|
|
if (p.isMe || _notifiedMessages.has(p.id)) continue;
|
|
_notifiedMessages.add(p.id);
|
|
if (_notifiedMessages.size > 1) { // skip first load
|
|
const name = thread.partnerName || partnerId.slice(0, 8);
|
|
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
|
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Preserve expanded conversation state
|
|
const expandedPartner = conversationsList.querySelector('.conversation-active')?.dataset?.partner || null;
|
|
|
|
// Render conversation list
|
|
if (sortedThreads.length === 0) {
|
|
conversationsList.innerHTML = `<div class="section-card">${renderEmptyState(
|
|
'No conversations yet',
|
|
'Send a DM above to start a conversation.'
|
|
)}</div>`;
|
|
} else {
|
|
conversationsList.innerHTML = sortedThreads.map(([partnerId, thread]) => {
|
|
const lastMsg = thread.posts[thread.posts.length - 1];
|
|
const icon = generateIdenticon(partnerId, 22);
|
|
const name = escapeHtml(peerLabel(partnerId, thread.partnerName));
|
|
const time = relativeTime(lastMsg.timestampMs);
|
|
const preview = lastMsg.decryptedContent || lastMsg.content || '';
|
|
const previewText = escapeHtml(preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
|
|
const prefix = lastMsg.isMe ? '<span class="conv-you">You: </span>' : '';
|
|
|
|
const messagesHtml = thread.posts.map(p => {
|
|
const content = p.decryptedContent || p.content || '';
|
|
const msgTime = relativeTime(p.timestampMs);
|
|
const side = p.isMe ? 'chat-mine' : 'chat-theirs';
|
|
return `<div class="chat-bubble ${side}">
|
|
<div class="chat-text">${escapeHtml(content)}</div>
|
|
<div class="chat-time">${msgTime}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `<div class="conversation-item section-card" data-partner="${partnerId}">
|
|
<div class="conv-header">
|
|
<div class="conv-header-left">${icon} <span class="conv-name">${name}</span></div>
|
|
<span class="conv-time">${time}</span>
|
|
</div>
|
|
<div class="conv-preview">${prefix}${previewText}</div>
|
|
<div class="conversation-messages hidden">
|
|
<div class="chat-window">${messagesHtml}</div>
|
|
<div class="conv-reply">
|
|
<textarea class="conv-reply-input" placeholder="Reply..." rows="2"></textarea>
|
|
<button class="btn btn-primary btn-sm conv-reply-btn" data-partner="${partnerId}">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Open conversation in popover on click
|
|
conversationsList.querySelectorAll('.conversation-item').forEach(item => {
|
|
item.querySelector('.conv-header').addEventListener('click', () => {
|
|
const partnerId = item.dataset.partner;
|
|
const msgsHtml = item.querySelector('.chat-window').innerHTML;
|
|
const partnerName = item.querySelector('.conv-name').textContent;
|
|
|
|
openPopover(partnerName, `
|
|
<div class="chat-window" style="max-height:55vh;overflow-y:auto;display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem 0">${msgsHtml}</div>
|
|
<div class="conv-reply" style="display:flex;gap:0.4rem;align-items:flex-end;margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #2a2a40">
|
|
<textarea class="conv-reply-input" id="popover-reply-input" placeholder="Reply..." rows="2" style="flex:1;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:none;font-family:inherit;font-size:0.85rem;min-height:36px;line-height:1.4"></textarea>
|
|
<button class="btn btn-primary btn-sm" id="popover-reply-btn">Send</button>
|
|
</div>
|
|
`, {
|
|
onOpen() {
|
|
// Scroll chat to bottom
|
|
const chatWindow = $('#popover-body .chat-window');
|
|
if (chatWindow) chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
// Focus reply
|
|
const input = $('#popover-reply-input');
|
|
if (input) setTimeout(() => input.focus(), 100);
|
|
// Wire send
|
|
const sendReply = async () => {
|
|
const content = input.value.trim();
|
|
if (!content) return;
|
|
$('#popover-reply-btn').disabled = true;
|
|
try {
|
|
await invoke('create_post', { content, visibility: 'direct', recipientHex: partnerId });
|
|
input.value = '';
|
|
toast('Reply sent!');
|
|
closePopover();
|
|
loadMessages(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
const btn = $('#popover-reply-btn');
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
};
|
|
$('#popover-reply-btn').addEventListener('click', sendReply);
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) sendReply(); });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Conversations open in popovers now — no inline restore needed
|
|
}
|
|
|
|
// Message requests from non-followed
|
|
if (requests.length === 0) {
|
|
messageRequestsList.innerHTML = `<p class="empty-hint">No pending requests</p>`;
|
|
} else {
|
|
messageRequestsList.innerHTML = requests.map((p, i) => renderMessage(p, i, true)).join('');
|
|
attachFollowHandlers(messageRequestsList);
|
|
}
|
|
|
|
updateTabBadge('messages', requests.length);
|
|
} catch (e) {
|
|
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
|
|
}
|
|
}
|
|
|
|
async function loadDmRecipientOptions() {
|
|
try {
|
|
const [follows, peers] = await Promise.all([
|
|
invoke('list_follows'),
|
|
invoke('list_peers'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
const otherPeers = peers.filter(p => !followSet.has(p.nodeId) && p.nodeId !== myNodeId);
|
|
|
|
let html = '<option value="">(select recipient)</option>';
|
|
if (follows.length > 0) {
|
|
html += '<optgroup label="Following">';
|
|
html += follows.map(f => {
|
|
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${f.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
html += '</optgroup>';
|
|
}
|
|
if (otherPeers.length > 0) {
|
|
html += '<optgroup label="Other Peers">';
|
|
html += otherPeers.map(p => {
|
|
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${p.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
html += '</optgroup>';
|
|
}
|
|
dmRecipientSelect.innerHTML = html;
|
|
} catch (e) {
|
|
dmRecipientSelect.innerHTML = '<option value="">Error loading</option>';
|
|
}
|
|
}
|
|
|
|
async function loadPeers() {
|
|
if (!peersList) return;
|
|
try {
|
|
const [peers, follows] = await Promise.all([
|
|
invoke('list_peers'),
|
|
invoke('list_follows'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
|
|
if (peers.length === 0) {
|
|
peersList.innerHTML = renderEmptyState('No known peers', 'Connect to a peer using their connect string, or wait for mDNS discovery.');
|
|
} else {
|
|
// Sort by reach level, then by last_seen within each level
|
|
const reachOrder = { mesh: 0, n1: 1, n2: 2, n3: 3, known: 4 };
|
|
const sorted = [...peers].sort((a, b) => {
|
|
const ra = reachOrder[a.reach] ?? 4;
|
|
const rb = reachOrder[b.reach] ?? 4;
|
|
if (ra !== rb) return ra - rb;
|
|
return (b.lastSeen || 0) - (a.lastSeen || 0);
|
|
});
|
|
|
|
peersList.innerHTML = sorted.map(p => {
|
|
const label = escapeHtml(peerLabel(p.nodeId, p.displayName));
|
|
const icon = generateIdenticon(p.nodeId, 18);
|
|
const anchor = p.isAnchor ? '<span class="anchor-badge">anchor</span>' : '';
|
|
const intro = p.introducedBy ? `<span class="intro-tag">via ${escapeHtml(p.introducedBy)}</span>` : '';
|
|
const addr = p.addresses.length > 0 ? `<span class="peer-addr">${escapeHtml(p.addresses[0])}</span>` : '';
|
|
const seen = p.lastSeen ? `<span class="peer-seen">${relativeTime(p.lastSeen)}</span>` : '';
|
|
|
|
let reachBadge = '';
|
|
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
|
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
|
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
|
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
|
|
|
let actions = '';
|
|
if (p.nodeId === myNodeId) {
|
|
actions = '<span class="self-tag">(you)</span>';
|
|
} else {
|
|
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>`;
|
|
const followBtn = followSet.has(p.nodeId)
|
|
? `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${p.nodeId}">Unfollow</button>`
|
|
: `<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>`;
|
|
actions = `${msgBtn} ${followBtn}`;
|
|
}
|
|
|
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${p.nodeId}">${label}</a> ${reachBadge} ${anchor}</div>
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-meta">${intro} ${addr} ${seen}</div>
|
|
<div class="peer-card-actions">${actions}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
attachFollowHandlers(peersList);
|
|
// Lazy-load bios
|
|
loadPeerBios(peersList);
|
|
}
|
|
} catch (e) {
|
|
peersList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadPeerBios(container) {
|
|
const cards = container.querySelectorAll('[data-node-id]');
|
|
for (const card of cards) {
|
|
const nodeId = card.dataset.nodeId;
|
|
if (!nodeId || nodeId === myNodeId) continue;
|
|
try {
|
|
const info = await invoke('resolve_display', { nodeIdHex: nodeId });
|
|
const bioEl = card.querySelector('.peer-card-bio');
|
|
if (bioEl && info.bio) {
|
|
bioEl.textContent = info.bio;
|
|
bioEl.classList.add('peer-bio');
|
|
}
|
|
} catch (e) { /* ignore — peer may not have profile */ }
|
|
}
|
|
}
|
|
|
|
async function loadFollows() {
|
|
try {
|
|
const [follows, outbound, inbound] = await Promise.all([
|
|
invoke('list_follows'),
|
|
invoke('list_audience_outbound'),
|
|
invoke('list_audience'),
|
|
]);
|
|
const outboundSet = new Set(outbound.map(r => r.nodeId));
|
|
const approvedSet = new Set(outbound.filter(r => r.status === 'approved').map(r => r.nodeId));
|
|
const inboundApprovedSet = new Set(inbound.filter(r => r.status === 'approved').map(r => r.nodeId));
|
|
|
|
if (follows.length === 0) {
|
|
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
|
|
} else {
|
|
const now = Date.now();
|
|
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
const renderFollowCard = (f) => {
|
|
const icon = generateIdenticon(f.nodeId, 18);
|
|
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
|
const isSelf = f.nodeId === myNodeId;
|
|
|
|
let audienceBadge = '';
|
|
let mutualBadge = '';
|
|
let lastSeenHtml = '';
|
|
let actions = '';
|
|
if (isSelf) {
|
|
actions = '<span class="self-tag">(you)</span>';
|
|
} else {
|
|
if (inboundApprovedSet.has(f.nodeId)) {
|
|
mutualBadge = '<span class="mutual-badge">mutual</span>';
|
|
}
|
|
if (approvedSet.has(f.nodeId)) {
|
|
audienceBadge = '<span class="audience-badge">audience</span>';
|
|
} else if (outboundSet.has(f.nodeId)) {
|
|
audienceBadge = '<span class="audience-badge pending">requested</span>';
|
|
}
|
|
if (!f.isOnline && f.lastActivityMs > 0) {
|
|
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
|
|
}
|
|
const audienceBtn = !approvedSet.has(f.nodeId) && !outboundSet.has(f.nodeId)
|
|
? `<button class="btn btn-ghost btn-sm request-audience-btn" data-node-id="${f.nodeId}">Ask to join audience</button>`
|
|
: '';
|
|
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
|
|
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
|
|
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
|
|
actions = `${audienceBtn} ${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
|
}
|
|
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a> ${mutualBadge} ${audienceBadge}</div>
|
|
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-actions">${actions}</div>
|
|
</div>`;
|
|
};
|
|
|
|
const online = follows.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD));
|
|
const offline = follows.filter(f => !online.includes(f));
|
|
|
|
let html = '';
|
|
if (online.length > 0) {
|
|
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
|
html += online.map(renderFollowCard).join('');
|
|
}
|
|
if (offline.length > 0) {
|
|
html += `<div class="follows-section-header">Following: Offline (${offline.length})</div>`;
|
|
html += offline.map(renderFollowCard).join('');
|
|
}
|
|
followsList.innerHTML = html;
|
|
|
|
// Attach unfollow handlers
|
|
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Unfollowed');
|
|
loadFollows();
|
|
|
|
loadStats();
|
|
loadFeed(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Attach per-peer sync handlers
|
|
followsList.querySelectorAll('.sync-peer-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Syncing...';
|
|
try {
|
|
await invoke('sync_from_peer', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Sync complete!');
|
|
loadFeed(true);
|
|
loadMyPosts(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sync';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Attach audience request handlers
|
|
followsList.querySelectorAll('.request-audience-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('request_audience', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Audience request sent!');
|
|
loadFollows();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Lazy-load bios
|
|
loadPeerBios(followsList);
|
|
}
|
|
} catch (e) {
|
|
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
// loadSuggested removed — Suggested Peers section removed from UI
|
|
|
|
async function loadDiscoverPeople() {
|
|
const container = $('#discover-list');
|
|
try {
|
|
const [peers, follows] = await Promise.all([
|
|
invoke('list_peers'),
|
|
invoke('list_follows'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
|
|
// Filter: has display name, not already followed, not self
|
|
const discoverable = peers.filter(p =>
|
|
p.displayName && !followSet.has(p.nodeId) && p.nodeId !== myNodeId
|
|
);
|
|
|
|
if (discoverable.length === 0) {
|
|
container.innerHTML = renderEmptyState(
|
|
'No new people found',
|
|
'Connect to more peers to discover people on the network.'
|
|
);
|
|
} else {
|
|
container.innerHTML = discoverable.map(p => {
|
|
const icon = generateIdenticon(p.nodeId, 18);
|
|
const label = escapeHtml(p.displayName);
|
|
let reachBadge = '';
|
|
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
|
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
|
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
|
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
|
|
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
<div class="peer-card-row">${icon} ${label} ${reachBadge}</div>
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-actions">
|
|
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
|
<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
attachFollowHandlers(container);
|
|
loadPeerBios(container);
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// Shared handler for follow/unfollow buttons in a container
|
|
function attachFollowHandlers(container) {
|
|
container.querySelectorAll('.follow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('follow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Followed! Syncing posts...');
|
|
loadFollows();
|
|
loadStats();
|
|
loadFeed(true);
|
|
if (currentTab === 'messages') loadMessages(true);
|
|
// Auto-sync triggers in backend; refresh feed again after a delay
|
|
setTimeout(() => loadFeed(true), 3000);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
container.querySelectorAll('.unfollow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Unfollowed');
|
|
loadFollows();
|
|
loadStats();
|
|
loadFeed(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadRedundancy() {
|
|
try {
|
|
const r = await invoke('get_redundancy_info');
|
|
const zeroClass = r.zeroReplicas > 0 ? 'warn' : 'ok';
|
|
const oneClass = r.oneReplica > 0 ? '' : 'ok';
|
|
redundancyPanel.innerHTML = `<div class="redundancy-grid">
|
|
<div class="redundancy-item ${zeroClass}">
|
|
<div class="redundancy-value">${r.zeroReplicas}</div>
|
|
<div class="redundancy-label">Unreplicated</div>
|
|
</div>
|
|
<div class="redundancy-item ${oneClass}">
|
|
<div class="redundancy-value">${r.oneReplica}</div>
|
|
<div class="redundancy-label">1 replica</div>
|
|
</div>
|
|
<div class="redundancy-item ok">
|
|
<div class="redundancy-value">${r.twoPlusReplicas}</div>
|
|
<div class="redundancy-label">2+ replicas</div>
|
|
</div>
|
|
<div class="redundancy-item">
|
|
<div class="redundancy-value">${r.total}</div>
|
|
<div class="redundancy-label">Total posts</div>
|
|
</div>
|
|
</div>`;
|
|
} catch (e) {
|
|
redundancyPanel.innerHTML = `<p class="empty-hint">Could not load redundancy info</p>`;
|
|
}
|
|
}
|
|
|
|
// --- Audience management ---
|
|
async function loadAudience() {
|
|
try {
|
|
const records = await invoke('list_audience');
|
|
const pending = records.filter(r => r.status === 'pending');
|
|
const approved = records.filter(r => r.status === 'approved');
|
|
|
|
if (pending.length === 0) {
|
|
audiencePendingList.innerHTML = '<p class="empty-hint">No pending requests</p>';
|
|
} else {
|
|
audiencePendingList.innerHTML = pending.map(r => {
|
|
const label = escapeHtml(peerLabel(r.nodeId, r.displayName));
|
|
const icon = generateIdenticon(r.nodeId, 18);
|
|
return `<div class="peer-card">
|
|
<div class="peer-card-row">${icon} ${label}</div>
|
|
<div class="peer-card-meta"><span>${relativeTime(r.requestedAt)}</span></div>
|
|
<div class="peer-card-actions">
|
|
<button class="btn btn-primary btn-sm approve-audience-btn" data-node-id="${r.nodeId}">Approve</button>
|
|
<button class="btn btn-danger btn-sm deny-audience-btn" data-node-id="${r.nodeId}">Deny</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
audiencePendingList.querySelectorAll('.approve-audience-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('approve_audience', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Audience approved');
|
|
loadAudience();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
audiencePendingList.querySelectorAll('.deny-audience-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Audience denied');
|
|
loadAudience();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
}
|
|
|
|
if (approved.length === 0) {
|
|
audienceApprovedList.innerHTML = '<p class="empty-hint">No approved audience members</p>';
|
|
} else {
|
|
audienceApprovedList.innerHTML = approved.map(r => {
|
|
const label = escapeHtml(peerLabel(r.nodeId, r.displayName));
|
|
const icon = generateIdenticon(r.nodeId, 18);
|
|
return `<div class="peer-card">
|
|
<div class="peer-card-row">${icon} ${label}</div>
|
|
<div class="peer-card-meta"><span>Approved ${r.approvedAt ? relativeTime(r.approvedAt) : ''}</span></div>
|
|
<div class="peer-card-actions">
|
|
<button class="btn btn-danger btn-sm remove-audience-btn" data-node-id="${r.nodeId}">Remove</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
audienceApprovedList.querySelectorAll('.remove-audience-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Remove this audience member?')) return;
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Audience member removed');
|
|
loadAudience();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
audiencePendingList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// --- Network diagnostics ---
|
|
async function loadNetworkSummary() {
|
|
if (!networkSummaryEl) return;
|
|
try {
|
|
const s = await invoke('get_network_summary');
|
|
networkSummaryEl.innerHTML = `<div class="diag-grid">
|
|
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Local</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Wide</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
|
|
</div>`;
|
|
} catch (e) {
|
|
networkSummaryEl.innerHTML = `<p class="empty-hint">Could not load network summary</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadConnections() {
|
|
if (!connectionsList) return;
|
|
try {
|
|
const conns = await invoke('list_connections');
|
|
if (conns.length === 0) {
|
|
connectionsList.innerHTML = '<p class="empty-hint">No active mesh connections</p>';
|
|
} else {
|
|
connectionsList.innerHTML = conns.map(c => {
|
|
const label = escapeHtml(peerLabel(c.nodeId, c.displayName));
|
|
const icon = generateIdenticon(c.nodeId, 18);
|
|
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
|
|
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
|
|
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
|
|
return `<div class="peer-card">
|
|
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${c.slotKind}</span></div>
|
|
<div class="peer-card-meta"><span>${duration}</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
connectionsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadAllDiagnostics() {
|
|
await Promise.all([loadNetworkSummary(), loadConnections(), loadPeers(), loadActivityLog()]);
|
|
lastDiagUpdate = Date.now();
|
|
const ts = $('#diag-update-time');
|
|
if (ts) ts.textContent = 'Updated ' + relativeTime(lastDiagUpdate);
|
|
}
|
|
|
|
let activityInterval = null;
|
|
|
|
async function loadActivityLog() {
|
|
try {
|
|
const data = await invoke('get_activity_log');
|
|
// Render timers
|
|
const timersEl = $('#activity-timers');
|
|
if (timersEl) {
|
|
const now = Date.now();
|
|
timersEl.innerHTML = renderTimer('Rebalance', data.rebalanceLastMs, data.rebalanceIntervalSecs, now)
|
|
+ renderTimer('Anchor Register', data.anchorRegisterLastMs, data.anchorRegisterIntervalSecs, now);
|
|
}
|
|
// Render events (newest first)
|
|
const logEl = $('#activity-log');
|
|
if (logEl) {
|
|
if (!data.events || data.events.length === 0) {
|
|
logEl.innerHTML = '<div class="activity-empty">No activity yet</div>';
|
|
} else {
|
|
const reversed = [...data.events].reverse();
|
|
logEl.innerHTML = reversed.map(e => {
|
|
const t = new Date(e.timestampMs);
|
|
const time = t.toTimeString().slice(0, 8);
|
|
const peer = e.peerId ? e.peerId.slice(0, 8) : '';
|
|
const fullId = e.peerId || '';
|
|
return `<div class="activity-entry level-${e.level}">
|
|
<span class="activity-time">${time}</span>
|
|
<span class="activity-badge badge-${e.category}">${e.category}</span>
|
|
<span class="activity-msg">${escapeHtml(e.message)}</span>
|
|
${peer ? `<span class="activity-peer" data-full-id="${fullId}">${peer}</span>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
// Attach click handlers for peer IDs
|
|
logEl.querySelectorAll('.activity-peer[data-full-id]').forEach(span => {
|
|
span.addEventListener('click', () => {
|
|
const expanded = span.parentElement.querySelector('.activity-peer-expanded');
|
|
if (expanded) { expanded.remove(); return; }
|
|
const div = document.createElement('div');
|
|
div.className = 'activity-peer-expanded';
|
|
div.textContent = span.dataset.fullId;
|
|
span.parentElement.appendChild(div);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const logEl = $('#activity-log');
|
|
if (logEl) logEl.innerHTML = `<div class="activity-empty">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderTimer(label, lastMs, intervalSecs, now) {
|
|
if (!lastMs || lastMs === 0) {
|
|
return `<div class="timer-card">
|
|
<div class="timer-label">${label}</div>
|
|
<div class="timer-value">--:--</div>
|
|
<div class="timer-bar"><div class="timer-bar-fill" style="width:0%"></div></div>
|
|
</div>`;
|
|
}
|
|
const intervalMs = intervalSecs * 1000;
|
|
const elapsed = now - lastMs;
|
|
const remaining = Math.max(0, intervalMs - elapsed);
|
|
const mins = Math.floor(remaining / 60000);
|
|
const secs = Math.floor((remaining % 60000) / 1000);
|
|
const pct = Math.min(100, (elapsed / intervalMs) * 100);
|
|
return `<div class="timer-card">
|
|
<div class="timer-label">${label}</div>
|
|
<div class="timer-value">${mins}:${secs.toString().padStart(2, '0')}</div>
|
|
<div class="timer-bar"><div class="timer-bar-fill" style="width:${pct.toFixed(0)}%"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function formatTimeAgo(timestampMs) {
|
|
const diff = Date.now() - timestampMs;
|
|
if (diff < 0 || timestampMs === 0) return 'unknown';
|
|
const secs = Math.floor(diff / 1000);
|
|
if (secs < 60) return 'just now';
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
// --- Anchor management ---
|
|
let currentAnchors = []; // hex node IDs
|
|
|
|
async function loadMyAnchors() {
|
|
try {
|
|
const [info, anchorPeers] = await Promise.all([
|
|
invoke('get_node_info'),
|
|
invoke('list_anchor_peers'),
|
|
]);
|
|
currentAnchors = info.anchors || [];
|
|
|
|
// Render current anchors
|
|
if (currentAnchors.length === 0) {
|
|
anchorsList.innerHTML = '<p class="empty-hint">No anchors set</p>';
|
|
} else {
|
|
// Get display names for anchor IDs
|
|
anchorsList.innerHTML = currentAnchors.map(aid => {
|
|
const peer = anchorPeers.find(p => p.nodeId === aid);
|
|
const name = peer ? peer.displayName : null;
|
|
const label = escapeHtml(peerLabel(aid, name));
|
|
const icon = generateIdenticon(aid, 18);
|
|
return `<div class="anchor-item">
|
|
<span class="peer-label">${icon} ${label}</span>
|
|
<button class="btn btn-danger btn-sm rm-anchor-btn" data-nid="${aid}">Remove</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Attach remove handlers
|
|
anchorsList.querySelectorAll('.rm-anchor-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => doRemoveAnchor(btn.dataset.nid));
|
|
});
|
|
}
|
|
|
|
// Populate add dropdown with anchor peers not already in our list
|
|
const available = anchorPeers.filter(p => !currentAnchors.includes(p.nodeId) && p.nodeId !== myNodeId);
|
|
if (available.length === 0) {
|
|
anchorAddSelect.innerHTML = '<option value="">(no anchor peers available)</option>';
|
|
} else {
|
|
anchorAddSelect.innerHTML = available.map(p => {
|
|
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${p.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
anchorsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function doAddAnchor() {
|
|
const nid = anchorAddSelect.value;
|
|
if (!nid) { toast('Select an anchor peer'); return; }
|
|
anchorAddBtn.disabled = true;
|
|
try {
|
|
const newAnchors = [...currentAnchors, nid];
|
|
await invoke('set_anchors', { anchors: newAnchors });
|
|
toast('Anchor added');
|
|
loadMyAnchors();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
anchorAddBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doRemoveAnchor(nid) {
|
|
try {
|
|
const newAnchors = currentAnchors.filter(a => a !== nid);
|
|
await invoke('set_anchors', { anchors: newAnchors });
|
|
toast('Anchor removed');
|
|
loadMyAnchors();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
}
|
|
}
|
|
|
|
anchorAddBtn.addEventListener('click', doAddAnchor);
|
|
|
|
async function loadKnownAnchors() {
|
|
const container = $('#known-anchors-list');
|
|
try {
|
|
const anchors = await invoke('list_known_anchors');
|
|
if (anchors.length === 0) {
|
|
container.innerHTML = '<p class="empty-hint">No discovered anchors yet</p>';
|
|
} else {
|
|
container.innerHTML = anchors.map(a => {
|
|
const icon = generateIdenticon(a.nodeId, 18);
|
|
const label = escapeHtml(peerLabel(a.nodeId, a.displayName));
|
|
const addr = a.addresses.length > 0 ? `<span class="peer-addr">${escapeHtml(a.addresses[0])}</span>` : '';
|
|
return `<div class="anchor-item">
|
|
<span class="peer-label">${icon} ${label}</span>
|
|
${addr}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// --- Attachment handling ---
|
|
function renderAttachmentPreview() {
|
|
if (selectedFiles.length === 0) {
|
|
attachmentPreview.innerHTML = '';
|
|
return;
|
|
}
|
|
attachmentPreview.innerHTML = selectedFiles.map((f, i) => {
|
|
const isImage = f.mime.startsWith('image/');
|
|
const isVideo = f.mime.startsWith('video/');
|
|
if (isImage) {
|
|
const blob = new Blob([f.data], { type: f.mime });
|
|
const url = URL.createObjectURL(blob);
|
|
return `<div class="attach-thumb">
|
|
<img src="${url}" alt="${escapeHtml(f.name)}" />
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}
|
|
if (isVideo) {
|
|
const blob = new Blob([f.data], { type: f.mime });
|
|
const url = URL.createObjectURL(blob);
|
|
return `<div class="attach-thumb">
|
|
<video src="${url}#t=0.1" muted preload="auto" width="64" height="64" style="object-fit:cover;border-radius:4px;border:1px solid #444"></video>
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}
|
|
return `<div class="attach-thumb">
|
|
<span class="attach-file-name">${escapeHtml(f.name)}</span>
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}).join('');
|
|
attachmentPreview.querySelectorAll('.attach-remove').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
selectedFiles.splice(parseInt(btn.dataset.idx), 1);
|
|
renderAttachmentPreview();
|
|
});
|
|
});
|
|
}
|
|
|
|
attachBtn.addEventListener('click', () => fileInput.click());
|
|
fileInput.addEventListener('change', () => {
|
|
const maxFiles = 4;
|
|
const maxSize = 10 * 1024 * 1024;
|
|
for (const file of fileInput.files) {
|
|
if (selectedFiles.length >= maxFiles) {
|
|
toast('Max 4 attachments');
|
|
break;
|
|
}
|
|
if (file.size > maxSize) {
|
|
toast(`${file.name} exceeds 10MB limit`);
|
|
continue;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
selectedFiles.push({ data: reader.result, mime: file.type || 'application/octet-stream', name: file.name });
|
|
renderAttachmentPreview();
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
fileInput.value = '';
|
|
});
|
|
|
|
async function loadBlobAsObjectUrl(cid, postId, mime) {
|
|
const b64 = await invoke('get_blob', { cidHex: cid, postIdHex: postId });
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return URL.createObjectURL(new Blob([bytes], { type: mime }));
|
|
}
|
|
|
|
async function loadPostMedia(container) {
|
|
const imgs = container.querySelectorAll('img[data-cid]');
|
|
for (const img of imgs) {
|
|
const cid = img.dataset.cid;
|
|
const postId = img.dataset.postId;
|
|
const mime = img.dataset.mime || 'image/jpeg';
|
|
try {
|
|
const filePath = await invoke('get_blob_path', { cidHex: cid });
|
|
if (filePath && window.__TAURI__?.core?.convertFileSrc) {
|
|
const assetUrl = window.__TAURI__.core.convertFileSrc(filePath);
|
|
img.onerror = async () => {
|
|
// Asset protocol failed — fall back to base64 IPC
|
|
img.onerror = null;
|
|
try { img.src = await loadBlobAsObjectUrl(cid, postId, mime); }
|
|
catch (_) { img.alt = 'Image unavailable'; img.classList.add('post-image-missing'); }
|
|
};
|
|
img.src = assetUrl;
|
|
} else {
|
|
img.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
}
|
|
} catch (e) {
|
|
img.alt = 'Image unavailable';
|
|
img.classList.add('post-image-missing');
|
|
}
|
|
}
|
|
const vids = container.querySelectorAll('video[data-cid]');
|
|
for (const vid of vids) {
|
|
const cid = vid.dataset.cid;
|
|
const postId = vid.dataset.postId;
|
|
const mime = vid.dataset.mime || 'video/mp4';
|
|
try {
|
|
vid.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
} catch (e) {
|
|
vid.poster = '';
|
|
vid.insertAdjacentHTML('afterend', '<span class="empty-hint">Video unavailable</span>');
|
|
vid.remove();
|
|
}
|
|
}
|
|
const audios = container.querySelectorAll('audio[data-cid]');
|
|
for (const aud of audios) {
|
|
const cid = aud.dataset.cid;
|
|
const postId = aud.dataset.postId;
|
|
const mime = aud.dataset.mime || 'audio/mpeg';
|
|
try {
|
|
aud.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
} catch (e) {
|
|
aud.insertAdjacentHTML('afterend', '<span class="empty-hint">Audio unavailable</span>');
|
|
aud.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- File attachment download (PDFs, docs, etc.) ---
|
|
document.addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('.file-download');
|
|
if (!btn) return;
|
|
const cid = btn.dataset.cid;
|
|
const postId = btn.dataset.postId;
|
|
const ext = btn.dataset.ext || 'bin';
|
|
const filename = `${cid.slice(0, 8)}.${ext}`;
|
|
const postEl = btn.closest('.post');
|
|
const author = postEl?.querySelector('.post-author')?.textContent?.trim() || 'Unknown';
|
|
showDownloadPrompt(filename, author, cid, postId);
|
|
});
|
|
|
|
function showDownloadPrompt(filename, author, cid, postId) {
|
|
const existing = document.querySelector('.download-prompt-overlay');
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'download-prompt-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="download-prompt">
|
|
<h3>Download File</h3>
|
|
<p class="download-filename">${escapeHtml(filename)}</p>
|
|
<p class="download-warning">From: <strong>${escapeHtml(author)}</strong><br>
|
|
Only download files from people you trust.</p>
|
|
<div class="download-prompt-btns">
|
|
<button class="btn btn-secondary download-cancel">Cancel</button>
|
|
<button class="btn btn-primary download-save" data-open="false">Download</button>
|
|
<button class="btn btn-primary download-open" data-open="true">Download & Open</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
overlay.querySelector('.download-cancel').onclick = () => overlay.remove();
|
|
overlay.onclick = (ev) => { if (ev.target === overlay) overlay.remove(); };
|
|
|
|
const doDownload = async (shouldOpen) => {
|
|
overlay.remove();
|
|
try {
|
|
const path = await invoke(shouldOpen ? 'save_and_open_blob' : 'save_blob', {
|
|
cidHex: cid, postIdHex: postId, filename
|
|
});
|
|
toast('Saved to ' + path);
|
|
} catch (err) {
|
|
toast('Download failed: ' + err);
|
|
}
|
|
};
|
|
overlay.querySelector('.download-save').onclick = () => doDownload(false);
|
|
overlay.querySelector('.download-open').onclick = () => doDownload(true);
|
|
}
|
|
|
|
// --- Video speed control ---
|
|
document.addEventListener('change', (e) => {
|
|
if (!e.target.classList.contains('video-speed')) return;
|
|
const vid = e.target.closest('.video-wrap')?.querySelector('video');
|
|
if (vid) vid.playbackRate = parseFloat(e.target.value);
|
|
});
|
|
|
|
// --- Video download ---
|
|
document.addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('.video-download');
|
|
if (!btn) return;
|
|
const cid = btn.dataset.cid;
|
|
const vid = btn.closest('.video-wrap')?.querySelector('video');
|
|
const postId = vid?.dataset?.postId;
|
|
try {
|
|
const path = await invoke('save_and_open_blob', {
|
|
cidHex: cid, postIdHex: postId, filename: `video_${cid.slice(0, 8)}.mp4`
|
|
});
|
|
toast('Saved to ' + path);
|
|
} catch (_) {
|
|
toast('Download failed');
|
|
}
|
|
});
|
|
|
|
// --- Video expand/collapse (double-click to toggle fullscreen-like view) ---
|
|
// --- Image lightbox (click to open full-size popup) ---
|
|
document.addEventListener('click', (e) => {
|
|
// Close lightbox on click
|
|
const lb = e.target.closest('.image-lightbox');
|
|
if (lb) { lb.remove(); return; }
|
|
|
|
const img = e.target.closest('img.post-image');
|
|
if (!img) return;
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
const fullImg = document.createElement('img');
|
|
fullImg.src = img.src;
|
|
overlay.appendChild(fullImg);
|
|
document.body.appendChild(overlay);
|
|
});
|
|
|
|
// --- Video expand/collapse (double-click to toggle fullscreen) ---
|
|
document.addEventListener('dblclick', (e) => {
|
|
const vid = e.target.closest('video.post-video');
|
|
if (!vid) return;
|
|
vid.classList.toggle('video-expanded');
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const lb = document.querySelector('.image-lightbox');
|
|
if (lb) lb.remove();
|
|
const vid = document.querySelector('.video-expanded');
|
|
if (vid) vid.classList.remove('video-expanded');
|
|
}
|
|
});
|
|
|
|
// --- Peer name click handler (toggle bio on People tab) ---
|
|
document.addEventListener('click', async (e) => {
|
|
const link = e.target.closest('.peer-name-link');
|
|
if (!link) return;
|
|
e.preventDefault();
|
|
const card = link.closest('.peer-card');
|
|
if (!card) return;
|
|
const bioEl = card.querySelector('.peer-card-bio');
|
|
if (!bioEl) return;
|
|
// Toggle expanded class
|
|
if (bioEl.classList.contains('bio-expanded')) {
|
|
bioEl.classList.remove('bio-expanded');
|
|
} else {
|
|
// Lazy-load bio if not yet loaded
|
|
if (!bioEl.textContent && link.dataset.nodeId) {
|
|
try {
|
|
const info = await invoke('resolve_display', { nodeIdHex: link.dataset.nodeId });
|
|
if (info.bio) {
|
|
bioEl.textContent = info.bio;
|
|
bioEl.classList.add('peer-bio');
|
|
} else {
|
|
bioEl.textContent = '(no bio)';
|
|
bioEl.classList.add('peer-bio');
|
|
}
|
|
} catch (_) {
|
|
bioEl.textContent = '(no bio)';
|
|
bioEl.classList.add('peer-bio');
|
|
}
|
|
}
|
|
bioEl.classList.add('bio-expanded');
|
|
}
|
|
});
|
|
|
|
// --- Author name click handler (navigate to People tab) ---
|
|
document.addEventListener('click', async (e) => {
|
|
const link = e.target.closest('.post-author-link');
|
|
if (!link) return;
|
|
e.preventDefault();
|
|
const nodeId = link.dataset.nodeId;
|
|
// Switch to People tab
|
|
document.querySelector('.tab[data-tab="people"]').click();
|
|
// Scroll to and highlight the peer card after load
|
|
await new Promise(r => setTimeout(r, 300));
|
|
const card = document.querySelector(`.peer-card[data-node-id="${nodeId}"]`);
|
|
if (card) {
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
card.classList.add('peer-card-highlight');
|
|
setTimeout(() => card.classList.remove('peer-card-highlight'), 2000);
|
|
}
|
|
});
|
|
|
|
// --- Message peer handler (event delegation) ---
|
|
document.addEventListener('click', async (e) => {
|
|
if (!e.target.classList.contains('msg-peer-btn')) return;
|
|
const nodeId = e.target.dataset.nodeId;
|
|
// Switch to Messages tab
|
|
document.querySelector('.tab[data-tab="messages"]').click();
|
|
// Wait for DM recipient options to load, then pre-select
|
|
await loadDmRecipientOptions();
|
|
dmRecipientSelect.value = nodeId;
|
|
dmContent.focus();
|
|
});
|
|
|
|
// --- Delete post handler (event delegation) ---
|
|
document.addEventListener('click', async (e) => {
|
|
if (!e.target.classList.contains('delete-post-btn')) return;
|
|
const postId = e.target.dataset.postId;
|
|
if (!confirm('Delete this post? This cannot be undone.')) return;
|
|
e.target.disabled = true;
|
|
try {
|
|
await invoke('delete_post', { postIdHex: postId });
|
|
toast('Post deleted');
|
|
loadFeed(true);
|
|
loadMyPosts(true);
|
|
loadStats();
|
|
} catch (err) {
|
|
toast('Error: ' + err);
|
|
} finally {
|
|
e.target.disabled = false;
|
|
}
|
|
});
|
|
|
|
// --- Engagement handlers (event delegation) ---
|
|
|
|
// Emoji picker for reactions
|
|
const EMOJI_SET = ['👍','❤️','😂','😢','🔥','👏','🎉','💯','🤔','👎'];
|
|
|
|
document.addEventListener('click', async (e) => {
|
|
// React button → show emoji picker
|
|
if (e.target.classList.contains('react-btn')) {
|
|
e.stopPropagation();
|
|
closeEmojiPicker();
|
|
const postId = e.target.dataset.postId;
|
|
const picker = document.createElement('div');
|
|
picker.className = 'emoji-picker';
|
|
picker.innerHTML = EMOJI_SET.map(em =>
|
|
`<button class="emoji-pick" data-post-id="${postId}" data-emoji="${em}">${em}</button>`
|
|
).join('');
|
|
e.target.parentElement.appendChild(picker);
|
|
return;
|
|
}
|
|
|
|
// Emoji pick → add reaction
|
|
if (e.target.classList.contains('emoji-pick')) {
|
|
const postId = e.target.dataset.postId;
|
|
const emoji = e.target.dataset.emoji;
|
|
closeEmojiPicker();
|
|
try {
|
|
await invoke('react_to_post', { postId, emoji, private: false });
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Reaction pill → toggle reaction
|
|
if (e.target.classList.contains('reaction-pill')) {
|
|
const postId = e.target.dataset.postId;
|
|
const emoji = e.target.dataset.emoji;
|
|
try {
|
|
if (e.target.classList.contains('reacted')) {
|
|
await invoke('remove_reaction', { postId, emoji });
|
|
} else {
|
|
await invoke('react_to_post', { postId, emoji, private: false });
|
|
}
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Share button → generate share link and copy to clipboard
|
|
if (e.target.classList.contains('share-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
try {
|
|
const link = await invoke('generate_share_link', { postIdHex: postId });
|
|
if (link) {
|
|
try {
|
|
await navigator.clipboard.writeText(link);
|
|
toast('Share link copied!');
|
|
} catch (clipErr) {
|
|
prompt('Copy your share link:', link);
|
|
}
|
|
} else {
|
|
toast('Only public posts can be shared');
|
|
}
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Comment toggle → expand/collapse thread
|
|
if (e.target.classList.contains('comment-toggle-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const threadEl = document.getElementById('comments-' + postId);
|
|
if (!threadEl) return;
|
|
if (threadEl.classList.contains('hidden')) {
|
|
threadEl.classList.remove('hidden');
|
|
await loadCommentThread(postId, threadEl);
|
|
} else {
|
|
threadEl.classList.add('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Comment send button
|
|
if (e.target.classList.contains('comment-send-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const input = e.target.parentElement.querySelector('.comment-input');
|
|
const content = input.value.trim();
|
|
if (!content) return;
|
|
e.target.disabled = true;
|
|
try {
|
|
await invoke('comment_on_post', { postId, content });
|
|
input.value = '';
|
|
const threadEl = document.getElementById('comments-' + postId);
|
|
if (threadEl) await loadCommentThread(postId, threadEl);
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
finally { e.target.disabled = false; }
|
|
return;
|
|
}
|
|
|
|
// Close emoji picker on outside click
|
|
closeEmojiPicker();
|
|
});
|
|
|
|
function closeEmojiPicker() {
|
|
document.querySelectorAll('.emoji-picker').forEach(p => p.remove());
|
|
}
|
|
|
|
async function loadCommentThread(postId, container) {
|
|
try {
|
|
const comments = await invoke('get_comment_thread', { postId });
|
|
let html = '';
|
|
for (const c of comments) {
|
|
const name = c.authorName || c.author.substring(0, 12);
|
|
const time = relativeTime(c.timestampMs);
|
|
html += `<div class="comment-bubble">
|
|
<span class="comment-author">${escapeHtml(name)}</span>
|
|
<span class="comment-text">${escapeHtml(c.content)}</span>
|
|
<span class="comment-time">${time}</span>
|
|
</div>`;
|
|
}
|
|
html += `<div class="comment-compose">
|
|
<input class="comment-input" placeholder="Write a comment..." />
|
|
<button class="btn btn-primary btn-sm comment-send-btn" data-post-id="${postId}">Send</button>
|
|
</div>`;
|
|
container.innerHTML = html;
|
|
|
|
// Ctrl+Enter to send
|
|
const input = container.querySelector('.comment-input');
|
|
input.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter' && ev.ctrlKey) {
|
|
container.querySelector('.comment-send-btn').click();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
container.innerHTML = `<p class="empty-hint">Error loading comments</p>`;
|
|
}
|
|
}
|
|
|
|
async function refreshPostEngagement(postId) {
|
|
try {
|
|
const counts = await invoke('get_reaction_counts', { postId });
|
|
const commentCount = await invoke('get_comments', { postId }).then(c => c.length);
|
|
const postEl = document.querySelector(`.post[data-post-id="${postId}"]`);
|
|
if (!postEl) return;
|
|
|
|
// Update reaction pills
|
|
const pillsContainer = postEl.querySelector('.reaction-pills');
|
|
if (pillsContainer) {
|
|
const reactBtn = pillsContainer.querySelector('.react-btn');
|
|
let pillsHtml = counts.map(r =>
|
|
`<button class="reaction-pill${r.reactedByMe ? ' reacted' : ''}" data-post-id="${postId}" data-emoji="${r.emoji}">${r.emoji} ${r.count}</button>`
|
|
).join('');
|
|
pillsContainer.innerHTML = pillsHtml;
|
|
// Re-add react button
|
|
const btn = document.createElement('button');
|
|
btn.className = 'react-btn';
|
|
btn.dataset.postId = postId;
|
|
btn.title = 'React';
|
|
btn.textContent = '+';
|
|
pillsContainer.appendChild(btn);
|
|
}
|
|
|
|
// Update comment count
|
|
const commentBtn = postEl.querySelector('.comment-toggle-btn');
|
|
if (commentBtn) {
|
|
commentBtn.textContent = commentCount > 0 ? `Comment (${commentCount})` : 'Comment';
|
|
}
|
|
} catch (err) {
|
|
// Silently ignore refresh errors
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
async function doPost() {
|
|
const content = postContent.value.trim();
|
|
if (!content && selectedFiles.length === 0) return;
|
|
postBtn.disabled = true;
|
|
try {
|
|
const vis = visibilitySelect.value;
|
|
const params = { content: content || '' };
|
|
if (vis !== 'public') {
|
|
params.visibility = vis;
|
|
}
|
|
if (vis === 'circle') {
|
|
params.circleName = circleSelect.value;
|
|
if (!params.circleName) {
|
|
toast('Select a circle first');
|
|
postBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
let result;
|
|
if (selectedFiles.length > 0) {
|
|
// Convert ArrayBuffers to base64 strings
|
|
const files = selectedFiles.map(f => {
|
|
const bytes = new Uint8Array(f.data);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
return [btoa(binary), f.mime];
|
|
});
|
|
params.files = files;
|
|
result = await invoke('create_post_with_files', params);
|
|
} else {
|
|
result = await invoke('create_post', params);
|
|
}
|
|
|
|
// Set engagement policy if non-default
|
|
const commentPerm = document.getElementById('comment-perm-select').value;
|
|
const reactPerm = document.getElementById('react-perm-select').value;
|
|
if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) {
|
|
try {
|
|
await invoke('set_comment_policy', {
|
|
postId: result.id,
|
|
allowComments: commentPerm,
|
|
allowReacts: reactPerm,
|
|
});
|
|
} catch (_) { /* best effort */ }
|
|
}
|
|
|
|
postContent.value = '';
|
|
postContent.style.height = '';
|
|
selectedFiles = [];
|
|
renderAttachmentPreview();
|
|
updateCharCount();
|
|
visibilitySelect.value = 'public';
|
|
updateVisibilityUI();
|
|
toast('Posted!');
|
|
loadFeed(true);
|
|
loadMyPosts(true);
|
|
loadStats();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
postBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doSendDM() {
|
|
const recipient = dmRecipientSelect.value;
|
|
const content = dmContent.value.trim();
|
|
if (!recipient) { toast('Select a recipient'); return; }
|
|
if (!content) { toast('Write a message'); return; }
|
|
dmSendBtn.disabled = true;
|
|
try {
|
|
await invoke('create_post', {
|
|
content,
|
|
visibility: 'direct',
|
|
recipientHex: recipient,
|
|
});
|
|
dmContent.value = '';
|
|
toast('Message sent!');
|
|
loadMessages(true);
|
|
loadStats();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
dmSendBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doConnect() {
|
|
const cs = connectInput.value.trim();
|
|
if (!cs) return;
|
|
connectBtn.disabled = true;
|
|
connectStatus.textContent = 'Connecting...';
|
|
connectStatus.className = '';
|
|
try {
|
|
const result = await invoke('connect_peer', { connectString: cs });
|
|
connectStatus.textContent = result;
|
|
connectStatus.className = 'status-ok';
|
|
connectInput.value = '';
|
|
loadFollows();
|
|
loadFeed(true);
|
|
loadStats();
|
|
} catch (e) {
|
|
connectStatus.textContent = 'Error: ' + e;
|
|
connectStatus.className = 'status-err';
|
|
} finally {
|
|
connectBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doSyncAll() {
|
|
syncBtn.disabled = true;
|
|
syncBtn.textContent = 'Syncing...';
|
|
try {
|
|
const result = await invoke('sync_all');
|
|
toast(result);
|
|
loadFeed(true);
|
|
if (currentTab === 'myposts') loadMyPosts(true);
|
|
if (currentTab === 'people') { loadFollows(); }
|
|
if (currentTab === 'messages') loadMessages(true);
|
|
if (currentTab === 'settings') { loadRedundancy(); loadPublicVisible(); if (diagnosticsInterval) loadAllDiagnostics(); }
|
|
loadStats();
|
|
} catch (e) {
|
|
toast('Sync error: ' + e);
|
|
} finally {
|
|
syncBtn.disabled = false;
|
|
syncBtn.textContent = 'Sync Now';
|
|
}
|
|
}
|
|
|
|
async function doSetupName() {
|
|
const name = setupName.value.trim();
|
|
if (!name) return;
|
|
setupBtn.disabled = true;
|
|
try {
|
|
await invoke('set_display_name', { name });
|
|
setupOverlay.classList.add('hidden');
|
|
toast('Welcome, ' + name + '!');
|
|
loadNodeInfo();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
setupBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doSaveProfile() {
|
|
const name = profileNameInput.value.trim();
|
|
const bio = profileBioInput.value.trim();
|
|
if (!name) {
|
|
toast('Display name is required');
|
|
return;
|
|
}
|
|
saveProfileBtn.disabled = true;
|
|
try {
|
|
await invoke('set_profile', { name, bio });
|
|
// Also save public_visible setting
|
|
const visible = $('#public-visible-check').checked;
|
|
await invoke('set_public_visible', { visible });
|
|
toast('Profile saved!');
|
|
loadNodeInfo();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
saveProfileBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// --- Profile visibility ---
|
|
async function loadPublicVisible() {
|
|
try {
|
|
const visible = await invoke('get_public_visible');
|
|
$('#public-visible-check').checked = visible;
|
|
} catch (e) {
|
|
console.error('loadPublicVisible:', e);
|
|
}
|
|
}
|
|
|
|
// --- Circle profiles ---
|
|
async function loadCircleProfiles() {
|
|
const container = $('#circle-profiles-list');
|
|
try {
|
|
const circles = await invoke('list_circles');
|
|
if (circles.length === 0) {
|
|
container.innerHTML = '<p class="empty-hint">Create a circle first in My Posts tab.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const c of circles) {
|
|
let profile = null;
|
|
try {
|
|
profile = await invoke('get_circle_profile', { circleName: c.name });
|
|
} catch (e) { /* no profile yet */ }
|
|
|
|
const dn = profile ? profile.displayName : '';
|
|
const bio = profile ? profile.bio : '';
|
|
|
|
html += `<div class="circle-profile-card section-card" style="margin-bottom:0.5rem;padding:0.75rem">
|
|
<strong>${escapeHtml(c.name)}</strong>
|
|
<div style="margin-top:0.25rem">
|
|
<label style="font-size:0.85rem">Display Name</label>
|
|
<input class="cp-name" data-circle="${escapeHtml(c.name)}" value="${escapeHtml(dn)}" placeholder="Circle display name" maxlength="50" />
|
|
<label style="font-size:0.85rem">Bio</label>
|
|
<textarea class="cp-bio" data-circle="${escapeHtml(c.name)}" placeholder="Circle bio" maxlength="200" rows="2">${escapeHtml(bio)}</textarea>
|
|
<div class="button-row" style="margin-top:0.25rem">
|
|
<button class="btn btn-primary btn-sm cp-save-btn" data-circle="${escapeHtml(c.name)}">Save</button>
|
|
${profile ? `<button class="btn btn-danger btn-sm cp-delete-btn" data-circle="${escapeHtml(c.name)}">Delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Attach save handlers
|
|
container.querySelectorAll('.cp-save-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const circle = btn.dataset.circle;
|
|
const nameInput = container.querySelector(`.cp-name[data-circle="${circle}"]`);
|
|
const bioInput = container.querySelector(`.cp-bio[data-circle="${circle}"]`);
|
|
const displayName = nameInput.value.trim();
|
|
const bio = bioInput.value.trim();
|
|
if (!displayName) {
|
|
toast('Display name is required');
|
|
return;
|
|
}
|
|
try {
|
|
await invoke('set_circle_profile', { circleName: circle, displayName, bio, avatarCid: null });
|
|
toast(`Circle profile for "${circle}" saved!`);
|
|
loadCircleProfiles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
|
|
// Attach delete handlers
|
|
container.querySelectorAll('.cp-delete-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const circle = btn.dataset.circle;
|
|
if (!confirm(`Delete circle profile for "${circle}"?`)) return;
|
|
try {
|
|
await invoke('delete_circle_profile', { circleName: circle });
|
|
toast(`Circle profile for "${circle}" deleted`);
|
|
loadCircleProfiles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
container.innerHTML = '<p class="empty-hint">Error loading circles.</p>';
|
|
console.error('loadCircleProfiles:', e);
|
|
}
|
|
}
|
|
|
|
// --- Visibility UI ---
|
|
function updateVisibilityUI() {
|
|
const vis = visibilitySelect.value;
|
|
circleSelect.classList.toggle('hidden', vis !== 'circle');
|
|
}
|
|
|
|
async function loadCircleOptions() {
|
|
try {
|
|
const circles = await invoke('list_circles');
|
|
circleSelect.innerHTML = circles.length === 0
|
|
? '<option value="">(no circles)</option>'
|
|
: circles.map(c => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)} (${c.members.length})</option>`).join('');
|
|
} catch (e) {
|
|
circleSelect.innerHTML = '<option value="">Error</option>';
|
|
}
|
|
}
|
|
|
|
visibilitySelect.addEventListener('change', () => {
|
|
updateVisibilityUI();
|
|
if (visibilitySelect.value === 'circle') loadCircleOptions();
|
|
});
|
|
|
|
// --- Circles management ---
|
|
async function loadCircles() {
|
|
try {
|
|
const circles = await invoke('list_circles');
|
|
if (circles.length === 0) {
|
|
circlesList.innerHTML = '<p class="empty-hint">No circles yet. Create one above.</p>';
|
|
return;
|
|
}
|
|
// Also load follows for display names and add-member dropdown
|
|
const follows = await invoke('list_follows');
|
|
const nameMap = {};
|
|
follows.forEach(f => { nameMap[f.nodeId] = f.displayName; });
|
|
|
|
circlesList.innerHTML = circles.map(c => {
|
|
const memberHtml = c.members.length === 0
|
|
? '<span class="empty-hint">No members</span>'
|
|
: c.members.map(mid => {
|
|
const displayName = nameMap[mid] || (mid.substring(0, 12) + '...');
|
|
return `<span class="circle-member">${escapeHtml(displayName)} <button class="btn btn-ghost btn-sm rm-member-btn" data-circle="${escapeHtml(c.name)}" data-nid="${mid}">Remove</button></span>`;
|
|
}).join(' ');
|
|
|
|
const addOptions = follows
|
|
.filter(f => !c.members.includes(f.nodeId))
|
|
.map(f => {
|
|
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${f.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
|
|
return `<div class="circle-card">
|
|
<div class="circle-header">
|
|
<strong>${escapeHtml(c.name)}</strong> <span class="circle-count">(${c.members.length})</span>
|
|
<button class="btn btn-danger btn-sm del-circle-btn" data-name="${escapeHtml(c.name)}" style="margin-left:auto">Delete</button>
|
|
</div>
|
|
<div class="circle-members">${memberHtml}</div>
|
|
${addOptions ? `<div class="circle-add-row">
|
|
<select class="add-member-select">${addOptions}</select>
|
|
<button class="btn btn-primary btn-sm add-member-btn" data-circle="${escapeHtml(c.name)}">Add</button>
|
|
</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Attach handlers
|
|
circlesList.querySelectorAll('.del-circle-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm(`Delete circle "${btn.dataset.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
await invoke('delete_circle', { name: btn.dataset.name });
|
|
toast('Circle deleted');
|
|
loadCircles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
circlesList.querySelectorAll('.rm-member-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Remove this member from the circle?')) return;
|
|
try {
|
|
await invoke('remove_circle_member', { circleName: btn.dataset.circle, nodeIdHex: btn.dataset.nid });
|
|
toast('Member removed');
|
|
loadCircles();
|
|
// Offer to revoke past access
|
|
if (confirm('Revoke their access to past circle posts?')) {
|
|
try {
|
|
const count = await invoke('revoke_circle_access', {
|
|
circleName: btn.dataset.circle,
|
|
nodeIdHex: btn.dataset.nid,
|
|
});
|
|
toast(`Revoked access on ${count} posts`);
|
|
} catch (re) { toast('Revoke error: ' + re); }
|
|
}
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
circlesList.querySelectorAll('.add-member-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const select = btn.previousElementSibling;
|
|
if (!select.value) return;
|
|
try {
|
|
await invoke('add_circle_member', { circleName: btn.dataset.circle, nodeIdHex: select.value });
|
|
toast('Member added');
|
|
loadCircles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
circlesList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
createCircleBtn.addEventListener('click', async () => {
|
|
const name = circleNameInput.value.trim();
|
|
if (!name) return;
|
|
createCircleBtn.disabled = true;
|
|
try {
|
|
await invoke('create_circle', { name });
|
|
circleNameInput.value = '';
|
|
toast('Circle created!');
|
|
loadCircles();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
createCircleBtn.disabled = false;
|
|
}
|
|
});
|
|
circleNameInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') createCircleBtn.click();
|
|
});
|
|
|
|
// --- Tab switching ---
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
if (tab.dataset.tab === currentTab) return;
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
const oldView = document.querySelector('.view.active');
|
|
if (oldView) {
|
|
oldView.classList.remove('active');
|
|
oldView.classList.add('view-exit');
|
|
}
|
|
tab.classList.add('active');
|
|
const target = tab.dataset.tab;
|
|
const newView = document.querySelector(`#view-${target}`);
|
|
// Clear diagnostics auto-refresh when leaving Settings
|
|
if (diagnosticsInterval) { clearInterval(diagnosticsInterval); diagnosticsInterval = null; }
|
|
if (activityInterval) { clearInterval(activityInterval); activityInterval = null; }
|
|
requestAnimationFrame(() => {
|
|
if (oldView) oldView.classList.remove('view-exit');
|
|
newView.classList.add('active');
|
|
currentTab = target;
|
|
if (target === 'feed') {
|
|
if (!feedList.children.length) feedList.innerHTML = renderLoading();
|
|
loadFeed(true);
|
|
}
|
|
if (target === 'myposts') { loadMyPosts(true); loadCircles(); }
|
|
if (target === 'people') {
|
|
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
|
loadFollows(); loadAudience();
|
|
}
|
|
if (target === 'messages') {
|
|
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
|
loadMessages(true); loadDmRecipientOptions();
|
|
}
|
|
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); }
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- Collapsible section toggles ---
|
|
$('#circles-toggle').addEventListener('click', () => {
|
|
const body = $('#circles-body');
|
|
body.classList.toggle('hidden');
|
|
$('#circles-toggle').textContent = body.classList.contains('hidden') ? 'Manage Circles' : 'Hide Circles';
|
|
});
|
|
|
|
$('#discover-toggle').addEventListener('click', () => {
|
|
const body = $('#discover-body');
|
|
body.classList.toggle('hidden');
|
|
$('#discover-toggle').textContent = body.classList.contains('hidden') ? 'Discover People' : 'Hide Discover';
|
|
if (!body.classList.contains('hidden')) loadDiscoverPeople();
|
|
});
|
|
|
|
$('#anchors-toggle').addEventListener('click', () => {
|
|
const body = $('#anchors-body');
|
|
body.classList.toggle('hidden');
|
|
$('#anchors-toggle').textContent = body.classList.contains('hidden') ? 'Stored Anchors' : 'Hide Anchors';
|
|
if (!body.classList.contains('hidden')) {
|
|
loadKnownAnchors();
|
|
loadMyAnchors();
|
|
}
|
|
});
|
|
|
|
$('#diagnostics-btn').addEventListener('click', () => {
|
|
const diagHtml = `
|
|
<div id="network-summary"></div>
|
|
<div class="diag-actions">
|
|
<button id="diag-refresh-btn" class="btn btn-ghost btn-sm">Refresh</button>
|
|
<button id="rebalance-btn" class="btn btn-ghost btn-sm">Rebalance Now</button>
|
|
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
|
|
<span id="diag-update-time" class="diag-timestamp"></span>
|
|
</div>
|
|
<h4 class="subsection-title">Timers</h4>
|
|
<div id="activity-timers" class="diag-grid" style="grid-template-columns: 1fr 1fr;"></div>
|
|
<h4 class="subsection-title">Activity Log</h4>
|
|
<div id="activity-log" class="activity-log-container"></div>
|
|
<h4 class="subsection-title">Mesh Connections</h4>
|
|
<div id="connections-list"></div>
|
|
<h4 class="subsection-title">Known Peers</h4>
|
|
<div id="peers-list"></div>`;
|
|
openPopover('Network Diagnostics', diagHtml, {
|
|
onOpen() {
|
|
// Re-bind dynamic element refs
|
|
networkSummaryEl = $('#network-summary');
|
|
connectionsList = $('#connections-list');
|
|
peersList = $('#peers-list');
|
|
// Wire action buttons
|
|
$('#diag-refresh-btn').addEventListener('click', async () => {
|
|
const btn = $('#diag-refresh-btn');
|
|
btn.disabled = true; btn.textContent = 'Refreshing...';
|
|
try { await loadAllDiagnostics(); toast('Diagnostics refreshed'); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; btn.textContent = 'Refresh'; }
|
|
});
|
|
$('#rebalance-btn').addEventListener('click', async () => {
|
|
const btn = $('#rebalance-btn');
|
|
btn.disabled = true; btn.textContent = 'Rebalancing...';
|
|
try { const r = await invoke('trigger_rebalance'); toast(r); loadAllDiagnostics(); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; btn.textContent = 'Rebalance Now'; }
|
|
});
|
|
$('#request-referrals-btn').addEventListener('click', async () => {
|
|
const btn = $('#request-referrals-btn');
|
|
btn.disabled = true; btn.textContent = 'Requesting...';
|
|
try {
|
|
const r = await invoke('request_referrals');
|
|
btn.textContent = r;
|
|
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
|
|
loadAllDiagnostics();
|
|
} catch (e) {
|
|
btn.textContent = 'Failed';
|
|
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
|
|
}
|
|
});
|
|
loadAllDiagnostics();
|
|
diagnosticsInterval = setInterval(loadAllDiagnostics, 10000);
|
|
activityInterval = setInterval(loadActivityLog, 3000);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Event handlers ---
|
|
postBtn.addEventListener('click', doPost);
|
|
postContent.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && e.ctrlKey) doPost();
|
|
});
|
|
postContent.addEventListener('input', () => {
|
|
autoGrow(postContent);
|
|
updateCharCount();
|
|
});
|
|
dmSendBtn.addEventListener('click', doSendDM);
|
|
dmContent.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && e.ctrlKey) doSendDM();
|
|
});
|
|
connectBtn.addEventListener('click', doConnect);
|
|
connectInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doConnect();
|
|
});
|
|
$('#connect-toggle').addEventListener('click', () => {
|
|
const body = $('#connect-body');
|
|
body.classList.toggle('hidden');
|
|
$('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually...' : 'Cancel';
|
|
});
|
|
syncBtn.addEventListener('click', doSyncAll);
|
|
copyBtn.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(connectString);
|
|
toast('Connect string copied!');
|
|
} catch (e) {
|
|
console.error('Clipboard write failed:', e);
|
|
prompt('Copy your connect string:', connectString);
|
|
}
|
|
});
|
|
exportKeyBtn.addEventListener('click', async () => {
|
|
try {
|
|
const key = await invoke('export_identity');
|
|
try {
|
|
await navigator.clipboard.writeText(key);
|
|
toast('Identity key copied to clipboard. KEEP IT SECRET!');
|
|
} catch (clipErr) {
|
|
// Clipboard API may fail in some webview contexts — show the key instead
|
|
console.error('Clipboard write failed:', clipErr);
|
|
prompt('Copy your identity key (KEEP IT SECRET!):', key);
|
|
}
|
|
} catch (e) {
|
|
console.error('export_identity failed:', e);
|
|
toast('Error exporting key: ' + e);
|
|
}
|
|
});
|
|
saveProfileBtn.addEventListener('click', doSaveProfile);
|
|
// Mark profile inputs as touched so we don't overwrite user edits on auto-refresh
|
|
profileNameInput.addEventListener('input', () => { profileNameInput.dataset.touched = '1'; });
|
|
profileBioInput.addEventListener('input', () => { profileBioInput.dataset.touched = '1'; });
|
|
$('#circle-profiles-toggle').addEventListener('click', () => {
|
|
const body = $('#circle-profiles-body');
|
|
body.classList.toggle('hidden');
|
|
$('#circle-profiles-toggle').textContent = body.classList.contains('hidden') ? 'Circle Profiles' : 'Hide Circle Profiles';
|
|
if (!body.classList.contains('hidden')) loadCircleProfiles();
|
|
});
|
|
|
|
// --- Notifications popover ---
|
|
$('#notifications-btn').addEventListener('click', async () => {
|
|
// Load current settings
|
|
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
|
const postVal = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
|
|
const nearbyVal = await invoke('get_setting', { key: 'notif_nearby' }).catch(() => null) || 'on';
|
|
|
|
const html = `
|
|
<label for="notif-messages">Messages</label>
|
|
<select id="notif-messages">
|
|
<option value="off"${msgVal === 'off' ? ' selected' : ''}>Off</option>
|
|
<option value="on"${msgVal === 'on' ? ' selected' : ''}>On (no preview)</option>
|
|
<option value="preview"${msgVal === 'preview' ? ' selected' : ''}>On (with preview)</option>
|
|
</select>
|
|
<label for="notif-posts">Posts</label>
|
|
<select id="notif-posts">
|
|
<option value="off"${postVal === 'off' ? ' selected' : ''}>Off</option>
|
|
<option value="follows"${postVal === 'follows' ? ' selected' : ''}>From follows & audience</option>
|
|
<option value="recommended"${postVal === 'recommended' ? ' selected' : ''}>Recommended by follows</option>
|
|
<option value="popular"${postVal === 'popular' ? ' selected' : ''}>Popular (100+)</option>
|
|
</select>
|
|
<label for="notif-nearby">Nearby Users</label>
|
|
<select id="notif-nearby">
|
|
<option value="off"${nearbyVal === 'off' ? ' selected' : ''}>Off</option>
|
|
<option value="on"${nearbyVal === 'on' ? ' selected' : ''}>On</option>
|
|
</select>
|
|
<p class="empty-hint" style="margin-top:0.5rem">Changes are saved automatically.</p>`;
|
|
|
|
openPopover('Notifications', html, {
|
|
onOpen() {
|
|
for (const id of ['notif-messages', 'notif-posts', 'notif-nearby']) {
|
|
$(`#${id}`).addEventListener('change', async (e) => {
|
|
const key = id.replace('-', '_');
|
|
await invoke('set_setting', { key, value: e.target.value });
|
|
toast('Setting saved');
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
resetDataBtn.addEventListener('click', async () => {
|
|
if (!confirm('This will delete all posts, peers, and settings. Your identity key will be preserved. Continue?')) return;
|
|
if (!confirm('Are you sure? This cannot be undone.')) return;
|
|
resetDataBtn.disabled = true;
|
|
try {
|
|
const result = await invoke('reset_data');
|
|
toast(result);
|
|
resetDataBtn.textContent = 'Restart app to apply';
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
resetDataBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
setupBtn.addEventListener('click', doSetupName);
|
|
setupName.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doSetupName();
|
|
});
|
|
|
|
// --- Init ---
|
|
async function init() {
|
|
// Backend setup may still be running — retry until state is managed
|
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
try {
|
|
await invoke('get_node_info');
|
|
break; // backend ready
|
|
} catch (e) {
|
|
if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; }
|
|
await new Promise(r => setTimeout(r, 300));
|
|
}
|
|
}
|
|
|
|
updateCharCount();
|
|
const info = await loadNodeInfo();
|
|
await loadStats();
|
|
await loadFeed();
|
|
|
|
// Show setup overlay if no profile exists
|
|
if (info && !info.hasProfile) {
|
|
setupOverlay.classList.remove('hidden');
|
|
setupName.focus();
|
|
}
|
|
|
|
// Auto-refresh every 10 seconds (feed, posts, people, stats)
|
|
setInterval(() => {
|
|
if (currentTab === 'feed') loadFeed();
|
|
if (currentTab === 'myposts') loadMyPosts();
|
|
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
|
loadStats();
|
|
}, 10000);
|
|
|
|
// Tiered DM polling: frequency based on recency of last message
|
|
let _lastMsgPollMs = 0;
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
const elapsed = now - _lastMsgPollMs;
|
|
const lastMsgAge = now - _lastMsgTimestamp;
|
|
const HOUR = 3600000;
|
|
let interval;
|
|
if (currentTab === 'messages') interval = 5000; // on messages tab: 5s
|
|
else if (lastMsgAge < 4 * HOUR) interval = 5 * 60000; // <4h: 5min
|
|
else if (lastMsgAge < 3 * 24 * HOUR) interval = 15 * 60000; // <3d: 15min
|
|
else if (lastMsgAge < 30 * 24 * HOUR) interval = 4 * HOUR; // <30d: 4h
|
|
else interval = 24 * HOUR; // else: daily
|
|
if (elapsed >= interval) {
|
|
_lastMsgPollMs = now;
|
|
loadMessages();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
init();
|