itsgoin/frontend/app.js
Scott Reimers fb1e92985c Audit fixes: key permissions, lock contention, Docker IP filter, doc updates
Security: identity.key written with 0600 permissions (Unix). Docker bridge
IPs (172.17-31.x) filtered from is_shareable_addr to prevent topology
disclosure in relay introductions.

Lock contention: ManifestPush relay and DeleteRecord CDN notify now gather
connections under lock, then send outside lock.

UI: syncBtn null guard prevents crash on hidden element.

Documentation: design.html version badge updated to v0.4.4. Self Last
Encounter threshold corrected from 3h to 4h. Multi-Device Identity section
rewritten for multi-identity-per-device (complete) + multi-device (planned)
+ post merge (planned). MEMORY.md updated to v0.4.4+ status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:37:17 -04:00

3487 lines
154 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/Import buttons (placeholder — will be implemented in Phase 2/3)
$('#export-btn').addEventListener('click', () => { toast('Export coming soon'); });
$('#import-btn').addEventListener('click', () => { toast('Import coming soon'); });
$('#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);
// Wait for backend in the background, then load node info
(async () => {
for (let attempt = 0; attempt < 30; attempt++) {
try {
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();
}
// Reload feed now that backend is ready
loadFeed(true).catch(() => {});
loadMessages(true).catch(() => {});
})();
// 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();