itsgoin/frontend/app.js
Scott Reimers e354ccc388 Welcome screen: add Ready button with loading bar for instant feed access
Progress bar animates during backend readiness check. Once local feed is
loaded from SQLite, button enables with teal highlight. Click switches to
feed tab with cached content — no network wait needed for returning users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:41:50 -04:00

3665 lines
164 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');
// Stats bar removed — contextual counts shown in tab badges
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 = null; // removed — anchors are read-only
const anchorAddBtn = null;
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 = 'welcome';
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 (Tauri plugin) ---
let _notifReady = false;
let _activeNotificationIds = new Set();
async function maybeNotify(title, body, tag) {
try {
showTicker(title);
// Try Tauri plugin first, then Web Notification API, then invoke fallback
let sent = false;
const tauriNotif = window.__TAURI__?.notification || window.__TAURI__?.plugin?.notification;
if (tauriNotif) {
try {
const { isPermissionGranted, requestPermission, sendNotification } = tauriNotif;
let granted = await isPermissionGranted();
if (!granted) {
const perm = await requestPermission();
granted = perm === 'granted';
}
if (granted) {
sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined });
if (tag) _activeNotificationIds.add(tag);
sent = true;
}
} catch (_) {}
}
if (!sent && 'Notification' in window && Notification.permission !== 'denied') {
try {
if (Notification.permission === 'default') await Notification.requestPermission();
if (Notification.permission === 'granted') {
new Notification(title, { body, tag, silent: false });
sent = true;
}
} catch (_) {}
}
if (!sent) {
// Last resort: try invoke to Rust-side notification
try {
await invoke('send_notification', { title, body });
} catch (_) {}
}
} catch (_) {}
}
async function clearNotifications(tagPrefix) {
try {
if (window.__TAURI__?.notification) {
const { removeActive } = window.__TAURI__.notification;
if (!removeActive) return;
const toRemove = [..._activeNotificationIds].filter(t => t.startsWith(tagPrefix));
for (const tag of toRemove) {
try {
await removeActive({ notifications: [{ id: hashCode(tag) }] });
} catch (_) {}
_activeNotificationIds.delete(tag);
}
}
} catch (_) {}
}
function hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
hash = ((hash << 5) - hash) + ch;
hash |= 0;
}
return Math.abs(hash);
}
// --- 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} (${nodeId.substring(0, 6)})`;
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>';
}
const TAB_BASE_LABELS = { feed: 'Feed', myposts: 'My Posts', people: 'People', messages: 'Messages', settings: 'Settings' };
function updateTabBadge(tabName, count) {
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
if (!tab) return;
// Update the label span (preserve icon span)
const label = tab.querySelector('.tab-label');
const base = TAB_BASE_LABELS[tabName] || tabName;
if (label) {
label.textContent = base;
}
// Update or create badge span
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();
}
}
let _lastFeedViewMs = 0;
let _lastMyPostsViewMs = 0;
let _tickerTimeout = null;
let _lastTickerMs = 0;
function showTicker(msg) {
const now = Date.now();
if (now - _lastTickerMs < 1000) return; // max 1 update/sec
_lastTickerMs = now;
const el = $('#status-ticker');
if (!el) return;
el.textContent = msg;
el.classList.remove('faded');
if (_tickerTimeout) clearTimeout(_tickerTimeout);
_tickerTimeout = setTimeout(() => { el.classList.add('faded'); }, 3000);
}
async function updateNetworkIndicator() {
try {
const info = await invoke('get_network_summary');
const dot = $('#net-dot');
const labels = $('#net-labels');
if (!dot) return;
const total = info.totalConnections || 0;
dot.className = total === 0 ? 'net-black'
: total === 1 ? 'net-red'
: total <= 10 ? 'net-yellow'
: 'net-green';
dot.id = 'net-dot';
// Build labels
let labelHtml = '';
if (info.hasPublicV6) labelHtml += '<span class="net-label">Public</span>';
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
if (labels) labels.innerHTML = labelHtml;
// Only show connection ticker on state change
if (typeof updateNetworkIndicator._lastTotal === 'undefined') updateNetworkIndicator._lastTotal = -1;
if (total !== updateNetworkIndicator._lastTotal) {
if (total === 0) showTicker('Connecting...');
else if (updateNetworkIndicator._lastTotal === 0) showTicker('Connected');
else if (total > updateNetworkIndicator._lastTotal) showTicker(`${total} peers connected`);
updateNetworkIndicator._lastTotal = total;
}
} catch (_) {}
}
// --- 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 {
await invoke('get_stats');
// Stats bar removed — tab badges show contextual counts
} catch (e) {
console.error('loadStats:', e);
}
}
async function loadFeed(force) {
try {
const allPosts = await invoke('get_feed');
const posts = allPosts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
// 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;
const oldFp = _feedFingerprint;
_feedFingerprint = fp;
// Ticker for new posts from others
if (_notifReady && oldFp) {
const oldIds = new Set(oldFp.split('|').map(s => s.split(':')[0]));
for (const p of posts) {
if (!p.isMe && !oldIds.has(p.id)) {
const name = p.authorName || p.author.substring(0, 8);
showTicker(`New post from ${name}`);
break; // one ticker per cycle
}
}
}
// Notify on engagement (DB-backed seen tracking)
if (_notifReady && oldFp) {
try {
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
for (const p of posts) {
if (!p.isMe) continue;
if (notifReacts === 'off') continue;
// Get DB-persisted seen counts
const seen = await invoke('get_seen_engagement', { postId: p.id }).catch(() => ({ seenReactCount: 0, seenCommentCount: 0 }));
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
const totalComments = p.commentCount || 0;
if (totalReacts > seen.seenReactCount) {
const newReacts = totalReacts - seen.seenReactCount;
maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`);
showTicker(`New reaction on your post`);
}
if (totalComments > seen.seenCommentCount) {
const newComments = totalComments - seen.seenCommentCount;
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
showTicker(`New comment on your post`);
}
}
} catch (_) {}
}
// Skip full re-render if any video/audio is actively playing (prevents echo/restart)
const mediaPlaying = [...feedList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) {
// Don't destroy the DOM while media is playing — re-render on next cycle when stopped
return;
}
// Revoke old object URLs to prevent memory leaks
feedList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
// 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) {
// Don't lock in empty fingerprint — let next refresh re-render when posts arrive
_feedFingerprint = null;
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.intentKind !== 'direct' && !(p.intentKind === 'unknown' && p.recipients && p.recipients.length > 0));
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;
// Skip re-render if media is playing
const mediaPlaying = [...myPostsList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) return;
// Revoke old blob URLs
myPostsList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
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);
}
}
}
// Mark all visible own posts' engagement as seen (DB-backed) when viewing tab
if (currentTab === 'myposts') {
for (const p of mine) {
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
const totalComments = p.commentCount || 0;
if (totalReacts > 0 || totalComments > 0) {
invoke('mark_post_seen', { postId: p.id, reactCount: totalReacts, commentCount: totalComments }).catch(() => {});
}
}
}
} 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: intent-based with fallback for old posts without intentKind
const dms = posts.filter(p => {
if (p.intentKind === 'direct') return true;
// Fallback for pre-intent posts
if (p.intentKind === 'unknown' && !p.isMe && p.visibility === 'encrypted-for-me') return true;
if (p.intentKind === 'unknown' && 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 (DB-backed seen tracking)
try {
const notifMsg = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
if (_notifReady && notifMsg !== 'off') {
for (const [partnerId, thread] of sortedThreads) {
const lastReadMs = await invoke('get_last_read_message', { partnerIdHex: partnerId }).catch(() => 0);
for (const p of thread.posts) {
if (p.isMe) continue;
if (p.timestampMs <= lastReadMs) continue;
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}`);
showTicker(`New message from ${name}`);
}
}
}
} 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';
const isEncrypted = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
const receiptAttr = isEncrypted ? ` data-post-id="${p.id}"` : '';
const reactBtn = isEncrypted ? `<button class="slot-react-btn" data-post-id="${p.id}" title="React">+</button>` : '';
return `<div class="chat-bubble ${side}"${receiptAttr}>
<div class="chat-text">${escapeHtml(content)}</div>
<div class="chat-meta"><span class="chat-time">${msgTime}</span>${p.isMe && isEncrypted ? '<span class="receipt-indicator" data-post-id="' + p.id + '"></span>' : ''}${reactBtn}</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;
// Find the thread data from sortedThreads
const threadEntry = sortedThreads.find(([pid]) => pid === partnerId);
const threadPosts = threadEntry ? threadEntry[1].posts : [];
// Collect post IDs for receipt/seen tracking
const threadPostIds = threadPosts.filter(p => {
const enc = p.visibility === 'encrypted-for-me' || p.intentKind === 'direct' || (p.recipients && p.recipients.length > 0);
return enc;
}).map(p => p.id);
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 id="popover-slot-comments" style="max-height:15vh;overflow-y:auto;padding:0.3rem 0;border-top:1px solid #2a2a40;display:none"></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);
// Mark conversation as read (DB-backed) and clear notifications
invoke('mark_conversation_read', { partnerId }).catch(() => {});
clearNotifications(`msg-`);
// Mark incoming encrypted messages as "seen"
for (const p of threadPosts) {
if (!p.isMe && threadPostIds.includes(p.id)) {
invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {});
}
}
// Load receipt indicators for sent messages
loadReceiptIndicators(chatWindow);
// Wire react buttons
chatWindow.querySelectorAll('.slot-react-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pid = btn.dataset.postId;
const emoji = prompt('Emoji reaction:');
if (emoji && emoji.trim()) {
try {
await invoke('write_message_receipt', { postId: pid, receiptState: 'reacted', emoji: emoji.trim() });
toast('Reacted!');
} catch (err) {
toast('Error: ' + err);
}
}
});
});
// 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 });
invoke('mark_conversation_read', { partnerId }).catch(() => {});
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(); });
},
onClose() {
// Mark conversation as read when closing the popover
invoke('mark_conversation_read', { partnerId }).catch(() => {});
clearNotifications(`msg-`);
}
});
});
});
// 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);
}
// Count unread conversations (messages newer than last_read_ms)
let unreadCount = requests.length;
for (const [partnerId, thread] of sortedThreads) {
const lastReadMs = await invoke('get_last_read_message', { partnerIdHex: partnerId }).catch(() => 0);
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
if (hasUnread) unreadCount++;
}
updateTabBadge('messages', unreadCount);
} catch (e) {
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
}
// Load receipt indicators (checkmarks) for sent messages in a chat window
async function loadReceiptIndicators(chatWindow) {
if (!chatWindow) return;
const indicators = chatWindow.querySelectorAll('.receipt-indicator[data-post-id]');
for (const el of indicators) {
const postId = el.dataset.postId;
try {
const receipts = await invoke('get_message_receipts', { postId });
if (!receipts || receipts.length === 0) continue;
// Find the best state among non-self receipts
let bestState = 'empty';
let reactionEmoji = null;
for (const r of receipts) {
if (r.nodeId && r.nodeId !== myNodeId) {
if (r.state === 'reacted') { bestState = 'reacted'; reactionEmoji = r.emoji; break; }
if (r.state === 'seen' && bestState !== 'reacted') bestState = 'seen';
if (r.state === 'delivered' && bestState === 'empty') bestState = 'delivered';
}
}
if (bestState === 'delivered') {
el.innerHTML = '<span class="receipt-check" title="Delivered">&#10003;</span>';
} else if (bestState === 'seen') {
el.innerHTML = '<span class="receipt-check seen" title="Seen">&#10003;&#10003;</span>';
} else if (bestState === 'reacted') {
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '&#10003;&#10003;')}</span>`;
}
} catch (_) {}
}
}
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));
// Filter out self before rendering
const others = follows.filter(f => f.nodeId !== myNodeId);
if (others.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>`;
};
// If isOnline field isn't available (old build), show all as online
const hasOnlineField = others.some(f => f.isOnline !== undefined);
const online = hasOnlineField
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
: others;
const offline = hasOnlineField
? others.filter(f => !online.includes(f))
: [];
updateTabBadge('people', online.length);
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 follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
}
followsList.innerHTML = html;
// Open offline follows in lightbox
if (offline.length > 0) {
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const existing = document.querySelector('.offline-lightbox');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'offline-lightbox';
overlay.innerHTML = `
<div class="offline-lightbox-content">
<div class="offline-lightbox-header">
<h3>Following: Offline (${offline.length})</h3>
<button class="offline-lightbox-close">x</button>
</div>
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Wire up buttons inside the lightbox
attachFollowHandlers(overlay);
});
});
}
// 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">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</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 slotLabel = c.slotKind === 'Local' ? 'Mesh'
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</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() {
const tasks = [loadNetworkSummary(), loadActivityLog()];
// Only load connections if section is visible
const connSection = $('#connections-section');
if (connSection && !connSection.classList.contains('hidden')) {
tasks.push(loadConnections());
}
await Promise.all(tasks);
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');
// Timers removed — rebalance/anchor register countdowns not useful for users
// 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);
}
}
if (anchorAddBtn) 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, postIdHex: postId });
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);
vid.preload = 'auto';
} 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 postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : 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 postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : document.getElementById('comments-' + postId);
if (threadEl) await loadCommentThread(postId, threadEl);
refreshPostEngagement(postId);
} catch (err) { toast('Error: ' + err); }
finally { e.target.disabled = false; }
return;
}
// Edit comment
if (e.target.classList.contains('comment-edit-btn')) {
const postId = e.target.dataset.postId;
const ts = parseInt(e.target.dataset.ts);
const bubble = e.target.closest('.comment-bubble');
const textEl = bubble.querySelector('.comment-text');
const oldContent = textEl.textContent;
textEl.innerHTML = `<input class="comment-edit-input" value="${escapeHtml(oldContent)}" style="width:100%;background:#1a1a2e;color:#e0e0e0;border:1px solid #444;border-radius:3px;padding:0.2rem;font-size:0.8rem" />`;
const input = textEl.querySelector('.comment-edit-input');
input.focus();
input.addEventListener('keydown', async (ev) => {
if (ev.key === 'Enter') {
const newContent = input.value.trim();
if (newContent && newContent !== oldContent) {
try {
await invoke('edit_comment', { postId, timestampMs: ts, newContent });
const postEl = bubble.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
if (threadEl) await loadCommentThread(postId, threadEl);
toast('Comment edited');
} catch (err) { toast('Error: ' + err); }
} else {
textEl.textContent = oldContent;
}
} else if (ev.key === 'Escape') {
textEl.textContent = oldContent;
}
});
input.addEventListener('blur', () => {
if (textEl.querySelector('.comment-edit-input')) textEl.textContent = oldContent;
});
return;
}
// Delete comment
if (e.target.classList.contains('comment-delete-btn')) {
const postId = e.target.dataset.postId;
const ts = parseInt(e.target.dataset.ts);
if (!confirm('Delete this comment?')) return;
try {
await invoke('delete_comment', { postId, timestampMs: ts });
const postEl = e.target.closest('.post');
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
if (threadEl) await loadCommentThread(postId, threadEl);
refreshPostEngagement(postId);
toast('Comment deleted');
} catch (err) { toast('Error: ' + err); }
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);
const isMyComment = c.author === myNodeId;
const editDeleteBtns = isMyComment
? `<button class="comment-edit-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Edit">edit</button>
<button class="comment-delete-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Delete">del</button>`
: '';
html += `<div class="comment-bubble" data-post-id="${c.postId}" data-ts="${c.timestampMs}">
<span class="comment-author">${escapeHtml(name)}</span>
<span class="comment-text">${escapeHtml(c.content)}</span>
<span class="comment-time">${time} ${editDeleteBtns}</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,
});
// Mark conversation as read so we don't re-notify ourselves
invoke('mark_conversation_read', { partnerId: recipient }).catch(() => {});
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(); loadCacheStats(); 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);
}
}
// --- Cache storage settings ---
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
async function loadCacheStats() {
try {
const stats = await invoke('get_cache_stats');
const display = $('#cache-stats-display');
const maxLabel = stats.maxBytes === 0 ? 'Unlimited' : formatBytes(stats.maxBytes);
const pct = stats.maxBytes > 0 ? ` (${(stats.usedBytes / stats.maxBytes * 100).toFixed(1)}%)` : '';
display.textContent = `${formatBytes(stats.usedBytes)} used of ${maxLabel}${pct}${stats.blobCount} blobs`;
} catch (e) {
console.error('loadCacheStats:', e);
}
}
async function loadCacheSizeSetting() {
try {
const val = await invoke('get_setting', { key: 'cache_size_bytes' });
if (val) {
const sel = $('#cache-size-select');
// Match the option value
for (const opt of sel.options) {
if (opt.value === val) { sel.value = val; break; }
}
}
await loadCacheStats();
} catch (e) {
console.error('loadCacheSizeSetting:', e);
}
}
$('#cache-size-select').addEventListener('change', async () => {
const value = $('#cache-size-select').value;
try {
await invoke('set_setting', { key: 'cache_size_bytes', value });
toast('Cache size updated — takes effect on next eviction cycle');
await loadCacheStats();
} catch (e) {
toast('Error saving cache size: ' + 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;
// Close any open lightboxes/overlays/popovers
document.querySelectorAll('.image-lightbox, .download-prompt-overlay, .offline-lightbox').forEach(el => el.remove());
closePopover(); // hide the persistent popover overlay (don't remove it)
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') {
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
if (!feedList.children.length) feedList.innerHTML = renderLoading();
loadFeed(true);
}
if (target === 'myposts') {
_lastMyPostsViewMs = Date.now();
updateTabBadge('myposts', 0);
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();
clearNotifications('msg-');
}
if (target === 'settings') { loadIdentities(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
});
});
});
// --- Collapsible section toggles ---
$('#profile-lightbox-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
// Pre-fill from the settings fields
const currentName = profileNameInput.value || '';
const currentBio = profileBioInput.value || '';
const currentVisible = $('#public-visible-check')?.checked ?? true;
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:450px;width:90%;max-height:80vh;overflow-y:auto">
<h3 style="color:#7fdbca;margin:0 0 0.75rem;text-align:center">Profiles</h3>
<h4 style="color:#ccc;font-size:0.85rem;margin:0 0 0.4rem">Default Profile</h4>
<label style="font-size:0.8rem;color:#888">Display Name</label>
<input id="lb-profile-name" type="text" value="${escapeHtml(currentName)}" placeholder="Your name" maxlength="50" style="width:100%;margin-bottom:0.5rem" />
<label style="font-size:0.8rem;color:#888">Bio</label>
<textarea id="lb-profile-bio" placeholder="Tell people about yourself..." maxlength="200" rows="3" style="width:100%;margin-bottom:0.5rem">${escapeHtml(currentBio)}</textarea>
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
Show my profile to non-circle peers
</label>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
</div>
<hr style="border-color:#333;margin:1rem 0" />
<h4 style="color:#ccc;font-size:0.85rem;margin:0 0 0.4rem">Circle Profiles</h4>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Set a different name/bio for each circle you manage.</p>
<div id="lb-circle-profiles-list"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-ghost btn-sm" id="lb-profile-close">Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#lb-profile-save').addEventListener('click', async () => {
const name = overlay.querySelector('#lb-profile-name').value.trim();
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
if (!name) { toast('Display name is required'); return; }
try {
await invoke('set_profile', { name, bio });
const visible = overlay.querySelector('#lb-public-visible').checked;
await invoke('set_public_visible', { visible });
// Sync back to settings fields
profileNameInput.value = name;
profileBioInput.value = bio;
if ($('#public-visible-check')) $('#public-visible-check').checked = visible;
toast('Profile saved!');
loadNodeInfo();
overlay.remove();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#lb-profile-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Populate circle profiles list
const srcList = $('#circle-profiles-list');
const lbList = overlay.querySelector('#lb-circle-profiles-list');
if (srcList && lbList) lbList.innerHTML = srcList.innerHTML;
});
$('#redundancy-lightbox-btn').addEventListener('click', async () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%">
<h3 style="color:#7fdbca;margin:0 0 0.75rem;text-align:center">Redundancy</h3>
<div id="lb-redundancy-panel"><p class="empty-hint">Loading...</p></div>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-ghost btn-sm" id="lb-redundancy-close">Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
// Load redundancy data
try {
const r = await invoke('get_redundancy_info');
const zeroClass = r.zeroReplicas > 0 ? 'warn' : 'ok';
const oneClass = r.oneReplica > 0 ? '' : 'ok';
overlay.querySelector('#lb-redundancy-panel').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 (_) {
overlay.querySelector('#lb-redundancy-panel').innerHTML = '<p class="empty-hint">Could not load redundancy info</p>';
}
overlay.querySelector('#lb-redundancy-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#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();
}
});
function openDiagnostics() {
const diagHtml = `
<div id="network-summary"></div>
<div class="diag-actions" style="display:flex;gap:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="diag-refresh-btn" class="btn btn-ghost btn-sm">Refresh</button>
<button id="diag-sync-btn" class="btn btn-ghost btn-sm">Sync All</button>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<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>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-connections-btn" class="btn btn-ghost btn-sm">Show Connections</button>
<button id="show-anchors-btn" class="btn btn-ghost btn-sm">Stored Anchors</button>
</div>
<div id="connections-section" class="hidden">
<h4 class="subsection-title">Mesh &amp; Session Connections</h4>
<div id="connections-list"></div>
</div>
<div id="anchors-section" class="hidden">
<h4 class="subsection-title">Stored Anchors</h4>
<p class="empty-hint" style="margin-bottom:0.5rem">Anchors discovered for reconnection beyond bootstrap.</p>
<div id="diag-known-anchors-list"></div>
</div>
<h4 class="subsection-title">Activity Log</h4>
<div id="activity-log" class="activity-log-container"></div>`;
openPopover('Network Diagnostics', diagHtml, {
onOpen() {
// Re-bind dynamic element refs
networkSummaryEl = $('#network-summary');
connectionsList = $('#connections-list');
peersList = null; // Known peers removed
// Wire connections toggle
$('#show-connections-btn').addEventListener('click', () => {
const section = $('#connections-section');
const btn = $('#show-connections-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Hide Connections';
loadConnections();
} else {
section.classList.add('hidden');
btn.textContent = 'Show Connections';
}
});
// Wire anchors toggle
$('#show-anchors-btn').addEventListener('click', async () => {
const section = $('#anchors-section');
const btn = $('#show-anchors-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Hide Anchors';
try {
const anchors = await invoke('list_known_anchors');
const list = $('#diag-known-anchors-list');
if (list) list.innerHTML = anchors.length ? 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('') : '<p class="empty-hint">No discovered anchors</p>';
} catch (_) {}
} else {
section.classList.add('hidden');
btn.textContent = 'Stored Anchors';
}
});
// 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'; }
});
$('#diag-sync-btn').addEventListener('click', async () => {
const btn = $('#diag-sync-btn');
btn.disabled = true; btn.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);
} catch (e) { toast('Sync error: ' + e); }
finally { btn.disabled = false; btn.textContent = 'Sync All'; }
});
$('#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);
}
});
}
$('#diagnostics-btn').addEventListener('click', openDiagnostics);
$('#net-indicator').addEventListener('click', openDiagnostics);
// --- 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';
});
$('#share-details-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
let qrSvg = '';
try { qrSvg = generateQRCodeSVG(connectString, 200); } catch (_) {}
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Share My Details</h3>
<div style="margin-bottom:0.75rem">${qrSvg}</div>
<div class="connect-string-box" style="margin-bottom:0.75rem;font-size:0.7rem">${escapeHtml(connectString)}</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="share-copy-btn">Copy</button>
<button class="btn btn-ghost btn-sm" id="share-close-btn">Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#share-copy-btn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(connectString);
toast('Connect string copied!');
} catch (_) {
prompt('Copy your connect string:', connectString);
}
});
overlay.querySelector('#share-close-btn').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
if (syncBtn) syncBtn.addEventListener('click', doSyncAll);
if (copyBtn) 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);
}
});
if (exportKeyBtn) 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 ---
// Text size toggle
const TEXT_SIZE_SCALES = { xsmall: '75%', small: '100%', normal: '125%', large: '150%', xlarge: '200%' };
// Apply text size immediately from localStorage cache (no async wait)
const _cachedTextSize = localStorage.getItem('text_size') || 'normal';
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[_cachedTextSize] || '125%';
document.querySelectorAll('.text-size-opt').forEach(b => {
b.classList.toggle('active', b.dataset.size === _cachedTextSize);
});
document.querySelectorAll('.text-size-opt').forEach(btn => {
btn.addEventListener('click', async () => {
const size = btn.dataset.size;
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[size] || '';
document.querySelectorAll('.text-size-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
localStorage.setItem('text_size', size);
await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {});
toast('Text size updated');
});
});
// --- Identity management ---
async function loadIdentities() {
const list = $('#identities-list');
if (!list) return;
try {
const identities = await invoke('list_identities');
if (identities.length === 0) {
list.innerHTML = '<p class="empty-hint">No identities</p>';
return;
}
list.innerHTML = identities.map(id => {
const active = id.isActive ? ' <span style="color:#7fdbca;font-size:0.7rem">(active)</span>' : '';
const switchBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm switch-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem">Switch</button>`;
const deleteBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm delete-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222">
<div>
<span style="font-weight:600">${escapeHtml(id.displayName)}</span>${active}
<div style="font-size:0.6rem;color:#666">${id.nodeId.substring(0, 16)}...</div>
</div>
<div style="display:flex;gap:0.3rem">${switchBtn}${deleteBtn}</div>
</div>`;
}).join('');
// Wire switch buttons
list.querySelectorAll('.switch-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Switching...';
try {
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
toast('Identity switched — reloading...');
setTimeout(() => location.reload(), 1000);
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
});
});
// Wire delete buttons
list.querySelectorAll('.delete-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this identity? This cannot be undone.')) return;
try {
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
toast('Identity deleted');
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
});
} catch (e) {
list.innerHTML = `<p class="empty-hint">Error: ${e}</p>`;
}
}
$('#create-identity-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#new-id-create').addEventListener('click', async () => {
const name = overlay.querySelector('#new-id-name').value.trim();
if (!name) { toast('Name is required'); return; }
try {
const nodeId = await invoke('create_identity', { name });
toast(`Identity created: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#new-id-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#import-identity-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#import-id-go').addEventListener('click', async () => {
const keyHex = overlay.querySelector('#import-id-key').value.trim();
const name = overlay.querySelector('#import-id-name').value.trim() || 'Imported';
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
try {
const nodeId = await invoke('import_identity_key', { keyHex, name });
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#import-id-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Export wizard
$('#export-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key — safe to share)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
<input id="export-output-dir" type="text" value="Downloads" style="width:100%;margin-top:0.25rem;font-size:0.8rem" />
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="export-go">Export</button>
<button class="btn btn-ghost btn-sm" id="export-cancel">Cancel</button>
</div>
<div id="export-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#export-go').addEventListener('click', async () => {
const scope = overlay.querySelector('input[name="export-scope"]:checked')?.value;
if (!scope) { toast('Select a scope'); return; }
let outputDir = overlay.querySelector('#export-output-dir').value.trim() || 'Downloads';
// Resolve relative to home
if (!outputDir.startsWith('/')) {
// On desktop, use a reasonable default
outputDir = outputDir;
}
const status = overlay.querySelector('#export-status');
status.textContent = 'Exporting...';
overlay.querySelector('#export-go').disabled = true;
try {
const result = await invoke('export_data', { scope, outputDir });
status.textContent = result;
toast('Export complete!');
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Export failed: ' + e);
} finally {
overlay.querySelector('#export-go').disabled = false;
}
});
overlay.querySelector('#export-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Import wizard
$('#import-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Enter the path to an ItsGoin export ZIP file.</p>
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="width:100%;margin-bottom:0.75rem;font-size:0.8rem" />
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" checked /> Import public posts into current identity</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="merge_key" /> Merge with decryption key (decrypt + re-create)</label>
<div id="merge-key-input" style="display:none;margin-top:0.4rem">
<input id="import-merge-key" type="text" placeholder="Original identity key (64 hex chars)" maxlength="64" style="width:100%;font-family:monospace;font-size:0.7rem" />
</div>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-ghost btn-sm" id="import-preview">Preview</button>
<button class="btn btn-primary btn-sm" id="import-go" style="display:none">Import</button>
<button class="btn btn-ghost btn-sm" id="import-cancel">Cancel</button>
</div>
<div id="import-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#import-preview').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
if (!zipPath) { toast('Enter a ZIP path'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Reading...';
try {
const summaryJson = await invoke('import_summary', { zipPath });
const s = JSON.parse(summaryJson);
const box_ = overlay.querySelector('#import-summary-box');
box_.style.display = 'block';
box_.innerHTML = `
<div><strong>Node:</strong> ${s.node_id.substring(0, 16)}...</div>
<div><strong>Posts:</strong> ${s.post_count} &nbsp; <strong>Blobs:</strong> ${s.blob_count}</div>
<div><strong>Has key:</strong> ${s.has_identity_key ? 'Yes' : 'No'} &nbsp; <strong>Follows:</strong> ${s.has_follows ? 'Yes' : 'No'}</div>
<div><strong>Exported:</strong> ${new Date(s.export_date).toLocaleDateString()}</div>`;
overlay.querySelector('#import-action-box').style.display = 'block';
overlay.querySelector('#import-go').style.display = '';
// Hide "add as identity" if no key in export
if (!s.has_identity_key) {
const addIdRadio = overlay.querySelector('input[value="add_identity"]');
addIdRadio.disabled = true;
addIdRadio.parentElement.style.opacity = '0.4';
}
status.textContent = '';
} catch (e) {
status.textContent = 'Error: ' + e;
}
});
// Show/hide merge key input based on selected action
overlay.querySelectorAll('input[name="import-action"]').forEach(radio => {
radio.addEventListener('change', () => {
const mergeInput = overlay.querySelector('#merge-key-input');
mergeInput.style.display = radio.value === 'merge_key' && radio.checked ? 'block' : 'none';
});
});
overlay.querySelector('#import-go').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
if (!action) { toast('Select an import action'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Importing...';
overlay.querySelector('#import-go').disabled = true;
try {
let result;
if (action === 'add_identity') {
result = await invoke('import_as_new_identity', { zipPath });
} else if (action === 'merge_key') {
const keyHex = overlay.querySelector('#import-merge-key').value.trim();
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); overlay.querySelector('#import-go').disabled = false; return; }
result = await invoke('import_merge_with_key', { zipPath, keyHex });
} else {
result = await invoke('import_public_posts', { zipPath });
}
status.textContent = result;
toast('Import complete!');
loadFeed(true);
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Import failed: ' + e);
} finally {
overlay.querySelector('#import-go').disabled = false;
}
});
overlay.querySelector('#import-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#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 reactVal = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
const nearbyVal = await invoke('get_setting', { key: 'notif_nearby' }).catch(() => null) || 'on';
function btnGroup(label, key, value, options) {
const btns = options.map(([v, text]) =>
`<button class="notif-opt${v === value ? ' active' : ''}" data-key="${key}" data-value="${v}">${text}</button>`
).join('');
return `<div class="notif-row"><span class="notif-label">${label}</span><div class="notif-opts">${btns}</div></div>`;
}
const html = `
${btnGroup('Messages', 'notif_messages', msgVal, [['off','Off'],['on','On'],['preview','Preview']])}
${btnGroup('Posts', 'notif_posts', postVal, [['off','Off'],['follows','Follows'],['recommended','Recommended'],['popular','Popular']])}
${btnGroup('Reactions &amp; Comments', 'notif_reacts', reactVal, [['off','Off'],['on','On']])}
${btnGroup('Nearby Users', 'notif_nearby', nearbyVal, [['off','Off'],['on','On']])}
<p class="empty-hint" style="margin-top:0.75rem">Changes are saved automatically.</p>`;
openPopover('Notifications', html, {
onOpen() {
document.querySelectorAll('.notif-opt').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.key;
const value = btn.dataset.value;
// Update active state
btn.closest('.notif-opts').querySelectorAll('.notif-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
await invoke('set_setting', { key, 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() {
updateCharCount();
_lastFeedViewMs = Date.now();
updateNetworkIndicator().catch(() => {});
// Welcome screen — stagger count reveals every 2 seconds
let _welcomeTick = 0;
let _welcomeValues = [0, 0, 0, 0, 0];
const welcomeFields = ['welcome-connections', 'welcome-posts', 'welcome-messages', 'welcome-reacts', 'welcome-comments'];
// Fetch data in background (non-blocking — updates _welcomeValues + tab badges + notifications)
const welcomeFetch = () => {
invoke('get_network_summary').then(info => {
_welcomeValues[0] = info.totalConnections || 0;
}).catch(() => {});
invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }).then(b => {
_welcomeValues[1] = b.newFeed || 0;
_welcomeValues[2] = b.unreadMessages || 0;
_welcomeValues[3] = b.newReacts || 0;
_welcomeValues[4] = b.newComments || 0;
// Update tab badges from welcome screen
updateTabBadge('feed', b.newFeed || 0);
updateTabBadge('myposts', b.newEngagement || 0);
updateTabBadge('messages', b.unreadMessages || 0);
// Ticker + notifications only after user leaves welcome screen
// (welcome page already shows these counts directly)
}).catch(() => {});
};
// Stagger reveals — one field every 2 seconds (first fetch happens on first tick)
let _welcomeRevealed = 0;
const welcomeInterval = setInterval(() => {
if (currentTab !== 'welcome') {
clearInterval(welcomeInterval);
return;
}
// Reveal next field
if (_welcomeRevealed < welcomeFields.length) {
const el = document.getElementById(welcomeFields[_welcomeRevealed]);
if (el) el.textContent = _welcomeValues[_welcomeRevealed];
_welcomeRevealed++;
}
// Update all revealed fields with latest data
welcomeFetch();
for (let i = 0; i < _welcomeRevealed; i++) {
const el = document.getElementById(welcomeFields[i]);
if (el) el.textContent = _welcomeValues[i];
}
}, 2000);
// Ready button — click to go to feed
const readyBtn = document.getElementById('welcome-ready-btn');
const readyBar = document.getElementById('welcome-ready-bar');
if (readyBtn) {
readyBtn.addEventListener('click', () => {
if (readyBtn.disabled) return;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const feedTab = document.querySelector('.tab[data-tab="feed"]');
if (feedTab) feedTab.classList.add('active');
document.getElementById('view-feed').classList.add('active');
currentTab = 'feed';
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
});
}
// Wait for backend in the background, then load node info
(async () => {
for (let attempt = 0; attempt < 30; attempt++) {
try {
// Animate progress bar toward 90% during readiness checks
if (readyBar) readyBar.style.width = Math.min(90, (attempt + 1) * 3) + '%';
await invoke('get_node_info');
break;
} catch (e) {
if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; }
await new Promise(r => setTimeout(r, 300));
}
}
const info = await loadNodeInfo();
if (info && !info.hasProfile) {
setupOverlay.classList.remove('hidden');
setupName.focus();
}
// Pre-load feed + messages from local DB (instant — no network needed)
await loadFeed(true).catch(() => {});
loadMessages(true).catch(() => {});
// Mark ready button as clickable
if (readyBar) readyBar.style.width = '100%';
if (readyBtn) {
readyBtn.disabled = false;
readyBtn.textContent = 'Ready — Go to Feed';
readyBtn.style.opacity = '1';
readyBtn.style.color = '#7fdbca';
readyBtn.style.borderColor = '#7fdbca';
readyBtn.style.cursor = 'pointer';
}
})();
// Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam)
setTimeout(() => { _notifReady = true; }, 6000);
// Auto-refresh every 10 seconds — only the active tab
const _initTime = Date.now();
setInterval(() => {
const startup = Date.now() - _initTime < 30000; // force during first 30s
if (currentTab === 'feed') loadFeed(startup);
if (currentTab === 'myposts') loadMyPosts(startup);
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
updateNetworkIndicator();
}, 10000);
// Badge updates for non-active tabs — every 30 seconds (single IPC call)
setInterval(async () => {
try {
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
} catch (_) {}
}, 30000);
// 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();