itsgoin/frontend/app.js
Scott Reimers 800388cda4 ItsGoin v0.3.2 — Decentralized social media network
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>
2026-03-15 20:23:09 -04:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 &amp; 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 &amp; 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();