// 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(` `);
const mirrorCol = 4 - col;
if (mirrorCol !== col) {
cells.push(` `);
}
}
}
}
return ` ${cells.join('')} `;
}
// --- 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 += ` `;
}
}
}
return `
${rects}
`;
}
// --- 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 personaTag = post.asPersona
? ` as ${escapeHtml(post.asPersona)} `
: '';
const meTag = post.isMe ? ` (you)${personaTag}` : '';
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 = 'encrypted ';
} else if (post.visibility === 'encrypted') {
visBadge = 'encrypted ';
}
let displayContent;
if (post.visibility === 'encrypted' && !post.decryptedContent) {
displayContent = '(encrypted) ';
} else if (post.decryptedContent) {
displayContent = escapeHtml(post.decryptedContent);
} else {
displayContent = escapeHtml(post.content);
}
const deleteBtn = post.isMe
? `x `
: '';
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 += ` `;
}
for (const a of vids) {
inner += `
`;
}
for (const a of auds) {
inner += ` `;
}
for (const a of others) {
const sizeKb = Math.round(a.sizeBytes / 1024);
const ext = a.mimeType.split('/').pop() || 'file';
inner += `${escapeHtml(ext.toUpperCase())} (${sizeKb} KB) `;
}
attachmentsHtml = `${inner}
`;
}
const authorLink = post.isMe
? `${escapeHtml(authorName)}${meTag} `
: `${escapeHtml(authorName)} `;
// 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 =>
`${r.emoji} ${r.count} `
).join('');
const summary = totalResponses > 0 ? `${totalResponses} response${totalResponses !== 1 ? 's' : ''} ` : '';
reactionsHtml = pills + summary;
}
const commentCount = post.commentCount || 0;
const shareBtn = post.visibility === 'public'
? `Share `
: '';
const engagementBar = `
${reactionsHtml}\u263A
${shareBtn}
`;
const recipientsData = (post.recipients || []).join(',');
return `
${icon}${authorLink}${visBadge}
${timeStr}
${deleteBtn}
${displayContent}
${attachmentsHtml}
${engagementBar}
${post.id.substring(0, 16)}
`;
}
// 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 + `
Follow to accept
`;
}
function renderEmptyState(message, hint) {
return `
${escapeHtml(message)}
${hint ? `
${escapeHtml(hint)}
` : ''}
`;
}
function renderLoading() {
return '';
}
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 += 'Public ';
if (info.hasPublicV4 || info.hasUpnp) labelHtml += 'Server ';
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 + ' ' + escapeHtml(label) + ' ';
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 = 'QR code unavailable ';
}
}
// 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);
}
}
// --- Feed pagination state ---
let _feedCursor = null; // oldest_ms for next page
let _feedHasMore = true;
let _feedPrefetch = null; // pre-fetched next page (Promise)
let _feedLoading = false;
let _feedMediaObserver = null; // IntersectionObserver for viewport media
let _feedScrollObserver = null; // IntersectionObserver for infinite scroll
let _feedPostIds = new Set(); // track loaded post IDs to avoid duplicates
function filterFeedPosts(posts) {
return posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
}
async function loadFeed(force) {
if (_feedLoading) return;
_feedLoading = true;
try {
// First page or refresh: load newest 20
const result = await invoke('get_feed_page', { limit: 20 });
const posts = filterFeedPosts(result.posts);
// Fingerprint first page for refresh detection
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _feedFingerprint) { _feedLoading = false; return; }
const oldFp = _feedFingerprint;
_feedFingerprint = fp;
// Ticker for new posts
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)) {
showTicker(`New post from ${p.authorName || p.author.substring(0, 8)}`);
break;
}
}
}
// Skip re-render if media playing
const mediaPlaying = [...feedList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) { _feedLoading = false; return; }
// Revoke old blob URLs
feedList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
// Reset pagination state
_feedCursor = result.oldestMs || null;
_feedHasMore = result.hasMore;
_feedPostIds = new Set(posts.map(p => p.id));
if (posts.length === 0) {
_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('');
// Add scroll sentinel at midpoint
if (_feedHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'feed-scroll-sentinel';
const children = feedList.children;
const mid = Math.min(Math.floor(children.length / 2), children.length - 1);
children[mid].after(sentinel);
setupFeedScrollObserver();
}
setupFeedMediaObserver();
applyFeedPersonaFilter();
}
// Pre-fetch next page immediately
if (_feedHasMore && _feedCursor) {
_feedPrefetch = invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
feedList.innerHTML = `Error: ${e}
`;
} finally {
_feedLoading = false;
}
}
async function appendFeedPage() {
if (_feedLoading || !_feedHasMore) return;
_feedLoading = true;
try {
// Use pre-fetched data if available, otherwise fetch now
let result;
if (_feedPrefetch) {
result = await _feedPrefetch;
_feedPrefetch = null;
}
if (!result) {
result = await invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 });
}
const posts = filterFeedPosts(result.posts).filter(p => !_feedPostIds.has(p.id));
if (posts.length === 0) { _feedHasMore = false; _feedLoading = false; return; }
_feedCursor = result.oldestMs || null;
_feedHasMore = result.hasMore;
posts.forEach(p => _feedPostIds.add(p.id));
// Remove old sentinel
const oldSentinel = document.getElementById('feed-scroll-sentinel');
if (oldSentinel) oldSentinel.remove();
// Append posts
const fragment = document.createDocumentFragment();
const temp = document.createElement('div');
temp.innerHTML = posts.map(renderPost).join('');
while (temp.firstChild) fragment.appendChild(temp.firstChild);
// Insert new sentinel at midpoint of new posts
if (_feedHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'feed-scroll-sentinel';
const newNodes = [...fragment.children];
const mid = Math.min(Math.floor(newNodes.length / 2), newNodes.length - 1);
if (newNodes[mid]) newNodes[mid].after(sentinel);
}
feedList.appendChild(fragment);
applyFeedPersonaFilter();
setupFeedScrollObserver();
// Media observer auto-picks up new posts
// Pre-fetch next page
if (_feedHasMore && _feedCursor) {
_feedPrefetch = invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
console.error('appendFeedPage:', e);
} finally {
_feedLoading = false;
}
}
function setupFeedScrollObserver() {
if (_feedScrollObserver) _feedScrollObserver.disconnect();
const sentinel = document.getElementById('feed-scroll-sentinel');
if (!sentinel) return;
_feedScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) appendFeedPage();
}, { rootMargin: '200px' });
_feedScrollObserver.observe(sentinel);
}
function setupFeedMediaObserver() {
if (_feedMediaObserver) _feedMediaObserver.disconnect();
_feedMediaObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const post = entry.target;
if (!post.dataset.mediaLoaded) {
post.dataset.mediaLoaded = '1';
loadPostMedia(post);
}
}
}
}, { rootMargin: '400px' }); // start loading 400px before viewport
feedList.querySelectorAll('.post').forEach(post => _feedMediaObserver.observe(post));
// Also observe new posts added later via MutationObserver
const mutObs = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList && node.classList.contains('post')) {
_feedMediaObserver.observe(node);
}
}
}
});
mutObs.observe(feedList, { childList: true });
}
// --- My Posts pagination state ---
let _myPostsCursor = null;
let _myPostsHasMore = true;
let _myPostsPrefetch = null;
let _myPostsLoading = false;
let _myPostsMediaObserver = null;
let _myPostsScrollObserver = null;
let _myPostsIds = new Set();
async function loadMyPosts(force) {
if (_myPostsLoading) return;
_myPostsLoading = true;
try {
const result = await invoke('get_all_posts_page', { limit: 20 });
const mine = result.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) { _myPostsLoading = false; return; }
_myPostsFingerprint = fp;
const mediaPlaying = [...myPostsList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) { _myPostsLoading = false; return; }
myPostsList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
_myPostsCursor = result.oldestMs || null;
_myPostsHasMore = result.hasMore;
_myPostsIds = new Set(mine.map(p => p.id));
if (mine.length === 0) {
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
} else {
myPostsList.innerHTML = mine.map(renderPost).join('');
if (_myPostsHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'myposts-scroll-sentinel';
const children = myPostsList.children;
const mid = Math.min(Math.floor(children.length / 2), children.length - 1);
children[mid].after(sentinel);
setupMyPostsScrollObserver();
}
setupMyPostsMediaObserver();
}
// Mark visible own posts as seen
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(() => {});
}
}
}
if (_myPostsHasMore && _myPostsCursor) {
_myPostsPrefetch = invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
myPostsList.innerHTML = `Error: ${e}
`;
} finally {
_myPostsLoading = false;
}
}
async function appendMyPostsPage() {
if (_myPostsLoading || !_myPostsHasMore) return;
_myPostsLoading = true;
try {
let result = _myPostsPrefetch ? await _myPostsPrefetch : null;
_myPostsPrefetch = null;
if (!result) result = await invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 });
const mine = result.posts.filter(p => p.isMe && p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && p.recipients && p.recipients.length > 0))
.filter(p => !_myPostsIds.has(p.id));
if (mine.length === 0) { _myPostsHasMore = false; _myPostsLoading = false; return; }
_myPostsCursor = result.oldestMs || null;
_myPostsHasMore = result.hasMore;
mine.forEach(p => _myPostsIds.add(p.id));
const oldSentinel = document.getElementById('myposts-scroll-sentinel');
if (oldSentinel) oldSentinel.remove();
const fragment = document.createDocumentFragment();
const temp = document.createElement('div');
temp.innerHTML = mine.map(renderPost).join('');
while (temp.firstChild) fragment.appendChild(temp.firstChild);
if (_myPostsHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'myposts-scroll-sentinel';
const newNodes = [...fragment.children];
const mid = Math.min(Math.floor(newNodes.length / 2), newNodes.length - 1);
if (newNodes[mid]) newNodes[mid].after(sentinel);
}
myPostsList.appendChild(fragment);
setupMyPostsScrollObserver();
if (_myPostsHasMore && _myPostsCursor) {
_myPostsPrefetch = invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 }).catch(() => null);
}
} catch (_) {} finally { _myPostsLoading = false; }
}
function setupMyPostsScrollObserver() {
if (_myPostsScrollObserver) _myPostsScrollObserver.disconnect();
const sentinel = document.getElementById('myposts-scroll-sentinel');
if (!sentinel) return;
_myPostsScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) appendMyPostsPage();
}, { rootMargin: '200px' });
_myPostsScrollObserver.observe(sentinel);
}
function setupMyPostsMediaObserver() {
if (_myPostsMediaObserver) _myPostsMediaObserver.disconnect();
_myPostsMediaObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !entry.target.dataset.mediaLoaded) {
entry.target.dataset.mediaLoaded = '1';
loadPostMedia(entry.target);
}
}
}, { rootMargin: '400px' });
myPostsList.querySelectorAll('.post').forEach(post => _myPostsMediaObserver.observe(post));
const mutObs = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList && node.classList.contains('post')) _myPostsMediaObserver.observe(node);
}
}
});
mutObs.observe(myPostsList, { childList: true });
}
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 = `${renderEmptyState(
'No conversations yet',
'Send a DM above to start a conversation.'
)}
`;
} 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 ? 'You: ' : '';
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 ? `+ ` : '';
return `
${escapeHtml(content)}
${msgTime} ${p.isMe && isEncrypted ? ' ' : ''}${reactBtn}
`;
}).join('');
return ``;
}).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, `
${msgsHtml}
Send
`, {
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 = `No pending requests
`;
} 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 = ``;
}
}
// 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 = '✓ ';
} else if (bestState === 'seen') {
el.innerHTML = '✓✓ ';
} else if (bestState === 'reacted') {
el.innerHTML = `${escapeHtml(reactionEmoji || '✓✓')} `;
}
} 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 = '(select recipient) ';
if (follows.length > 0) {
html += '';
html += follows.map(f => {
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
return `${escapeHtml(label)} `;
}).join('');
html += ' ';
}
if (otherPeers.length > 0) {
html += '';
html += otherPeers.map(p => {
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
return `${escapeHtml(label)} `;
}).join('');
html += ' ';
}
dmRecipientSelect.innerHTML = html;
} catch (e) {
dmRecipientSelect.innerHTML = 'Error loading ';
}
}
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 ? 'anchor ' : '';
const intro = p.introducedBy ? `via ${escapeHtml(p.introducedBy)} ` : '';
const addr = p.addresses.length > 0 ? `${escapeHtml(p.addresses[0])} ` : '';
const seen = p.lastSeen ? `${relativeTime(p.lastSeen)} ` : '';
let reachBadge = '';
if (p.reach === 'mesh') reachBadge = 'Mesh ';
else if (p.reach === 'n1') reachBadge = 'N1 ';
else if (p.reach === 'n2') reachBadge = 'N2 ';
else if (p.reach === 'n3') reachBadge = 'N3 ';
let actions = '';
if (p.nodeId === myNodeId) {
actions = '(you) ';
} else {
const msgBtn = `msg `;
const followBtn = followSet.has(p.nodeId)
? `Unfollow `
: `Follow `;
actions = `${msgBtn} ${followBtn}`;
}
return `
${icon}
${label} ${reachBadge} ${anchor}
${intro} ${addr} ${seen}
${actions}
`;
}).join('');
attachFollowHandlers(peersList);
// Lazy-load bios
loadPeerBios(peersList);
}
} catch (e) {
peersList.innerHTML = `Error: ${e}
`;
}
}
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 {
// v0.6.2: audience removed. No more audience/mutual badges or request flow.
const follows = await invoke('list_follows');
// Filter out self before rendering
const others = follows.filter(f => f.nodeId !== myNodeId);
if (others.length === 0) {
followsList.innerHTML = `${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}
`;
} 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 lastSeenHtml = '';
let actions = '';
if (isSelf) {
actions = '(you) ';
} else {
if (!f.isOnline && f.lastActivityMs > 0) {
lastSeenHtml = `Last online: ${formatTimeAgo(f.lastActivityMs)} `;
}
const syncBtn = `Sync `;
const msgBtn = `msg `;
const unfollowBtn = `Unfollow `;
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
}
return `
${lastSeenHtml ? `
${lastSeenHtml}
` : ''}
${actions}
`;
};
// 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 += ``;
html += online.map(renderFollowCard).join('');
}
if (offline.length > 0) {
html += ``;
}
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 = `
${offline.map(renderFollowCard).join('')}
`;
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';
}
});
});
// Lazy-load bios
loadPeerBios(followsList);
}
} catch (e) {
followsList.innerHTML = `Error: ${e}
`;
}
}
// 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 = 'Mesh ';
else if (p.reach === 'n1') reachBadge = 'N1 ';
else if (p.reach === 'n2') reachBadge = 'N2 ';
else if (p.reach === 'n3') reachBadge = 'N3 ';
return `
${icon} ${label} ${reachBadge}
Follow
msg
`;
}).join('');
attachFollowHandlers(container);
loadPeerBios(container);
}
} catch (e) {
container.innerHTML = `Error: ${e}
`;
}
}
// 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 = `
${r.zeroReplicas}
Unreplicated
${r.oneReplica}
1 replica
${r.twoPlusReplicas}
2+ replicas
`;
} catch (e) {
redundancyPanel.innerHTML = `Could not load redundancy info
`;
}
}
// v0.6.2: audience removed. loadAudience is a no-op kept so existing call
// sites don't break; DOM panels (if still in markup) are hidden.
async function loadAudience() {
if (audiencePendingList) audiencePendingList.style.display = 'none';
if (audienceApprovedList) audienceApprovedList.style.display = 'none';
const headings = document.querySelectorAll('.audience-section, #audience-section');
headings.forEach(el => { el.style.display = 'none'; });
}
// --- Network diagnostics ---
async function loadNetworkSummary() {
if (!networkSummaryEl) return;
try {
const s = await invoke('get_network_summary');
networkSummaryEl.innerHTML = `
${s.totalConnections} Connections
${s.preferredCount} Preferred
${s.localCount} Mesh
${s.wideCount} Non-mesh N1
${s.n2Distinct} N2 Reach
${s.n3Distinct} N3 Reach
`;
} catch (e) {
networkSummaryEl.innerHTML = `Could not load network summary
`;
}
}
async function loadConnections() {
if (!connectionsList) return;
try {
const conns = await invoke('list_connections');
if (conns.length === 0) {
connectionsList.innerHTML = 'No active mesh connections
';
} 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 `
${icon} ${label} ${slotLabel}
${duration}
`;
}).join('');
}
} catch (e) {
connectionsList.innerHTML = `Error: ${e}
`;
}
}
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 = 'No activity yet
';
} 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 `
${time}
${e.category}
${escapeHtml(e.message)}
${peer ? `${peer} ` : ''}
`;
}).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 = `Error: ${e}
`;
}
}
function renderTimer(label, lastMs, intervalSecs, now) {
if (!lastMs || lastMs === 0) {
return ``;
}
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 `
${label}
${mins}:${secs.toString().padStart(2, '0')}
`;
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(//g, '>');
}
function formatTimeAgo(timestampMs) {
const diff = Date.now() - timestampMs;
if (diff < 0 || timestampMs === 0) return 'unknown';
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
// --- Anchor management ---
let currentAnchors = []; // hex node IDs
async function loadMyAnchors() {
try {
const [info, anchorPeers] = await Promise.all([
invoke('get_node_info'),
invoke('list_anchor_peers'),
]);
currentAnchors = info.anchors || [];
// Render current anchors
if (currentAnchors.length === 0) {
anchorsList.innerHTML = 'No anchors set
';
} 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 `
${icon} ${label}
Remove
`;
}).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 = '(no anchor peers available) ';
} else {
anchorAddSelect.innerHTML = available.map(p => {
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
return `${escapeHtml(label)} `;
}).join('');
}
} catch (e) {
anchorsList.innerHTML = `Error: ${e}
`;
}
}
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 = 'No discovered anchors yet
';
} 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 ? `${escapeHtml(a.addresses[0])} ` : '';
return `
${icon} ${label}
${addr}
`;
}).join('');
}
} catch (e) {
container.innerHTML = `Error: ${e}
`;
}
}
// --- 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 `
x
`;
}
if (isVideo) {
const blob = new Blob([f.data], { type: f.mime });
const url = URL.createObjectURL(blob);
return `
x
`;
}
return `
${escapeHtml(f.name)}
x
`;
}).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', 'Video unavailable ');
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', 'Audio unavailable ');
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 = `
Download File
${escapeHtml(filename)}
From: ${escapeHtml(author)}
Only download files from people you trust.
Cancel
Download
Download & Open
`;
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 — overlay background or image inside it (not form content)
if (e.target.classList.contains('image-lightbox')) { e.target.remove(); return; }
const lbParent = e.target.closest('.image-lightbox');
if (lbParent && e.target.tagName === 'IMG') { lbParent.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 =>
`${em} `
).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 = ``;
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
? `
`
: '';
html += ``;
}
html += ``;
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 = `Error loading comments
`;
}
}
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 =>
`${r.emoji} ${r.count} `
).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;
}
}
// If a non-default persona is picked in compose, route through create_post_as
const personaSel = $('#persona-select');
if (personaSel && !personaSel.classList.contains('hidden') && personaSel.value) {
const defaultEntry = personasCache.find(p => p.isDefault);
if (!defaultEntry || personaSel.value !== defaultEntry.nodeId) {
params.postingIdHex = personaSel.value;
}
}
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() {
// Name is optional — users who want to stay anonymous can proceed with a blank field.
const name = setupName.value.trim();
setupBtn.disabled = true;
try {
await invoke('set_display_name', { name });
setupOverlay.classList.add('hidden');
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
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 = 'Create a circle first in My Posts tab.
';
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 += ``;
}
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 = 'Error loading circles.
';
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
? '(no circles) '
: circles.map(c => `${escapeHtml(c.name)} (${c.members.length}) `).join('');
} catch (e) {
circleSelect.innerHTML = 'Error ';
}
}
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 = 'No circles yet. Create one above.
';
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
? 'No members '
: c.members.map(mid => {
const displayName = nameMap[mid] || (mid.substring(0, 12) + '...');
return `${escapeHtml(displayName)} Remove `;
}).join(' ');
const addOptions = follows
.filter(f => !c.members.includes(f.nodeId))
.map(f => {
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
return `${escapeHtml(label)} `;
}).join('');
return `
${memberHtml}
${addOptions ? `
${addOptions}
Add
` : ''}
`;
}).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 = `Error: ${e}
`;
}
}
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(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); }
});
});
});
// --- 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 = `
`;
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 = `
`;
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 = `
${r.zeroReplicas}
Unreplicated
${r.twoPlusReplicas}
2+ replicas
`;
} catch (_) {
overlay.querySelector('#lb-redundancy-panel').innerHTML = 'Could not load redundancy info
';
}
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 = `
Refresh
Sync All
Rebalance Now
Request Referrals
Our Info
Show Connections
Stored Anchors
Mesh & Session Connections
Stored Anchors
Anchors discovered for reconnection beyond bootstrap.
Activity Log
`;
openPopover('Network Diagnostics', diagHtml, {
onOpen() {
// Re-bind dynamic element refs
networkSummaryEl = $('#network-summary');
connectionsList = $('#connections-list');
peersList = null; // Known peers removed
// Wire Our Info toggle
$('#show-ourinfo-btn').addEventListener('click', async () => {
const section = $('#ourinfo-section');
const btn = $('#show-ourinfo-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Hide Our Info';
try {
const info = await invoke('get_our_info');
const httpVal = info.httpAddr || 'No';
let html = `
NAT Type ${info.natType}
Role ${info.deviceRole}
UPnP ${info.upnp ? 'Yes' : 'No'}
HTTP
${httpVal}
`;
html += '';
for (const a of info.addresses) {
const color = a.status.includes('Public') || a.status.includes('punchable') || a.status.includes('Server') ? '#7fdbca' :
a.status.includes('UPnP') || a.status.includes('External') ? '#5b8def' : '#888';
html += `
${a.addr}
${a.family} · ${a.status}
`;
}
html += '
';
html += `Node: ${info.nodeId.substring(0, 16)}…
`;
$('#ourinfo-content').innerHTML = html;
} catch (e) {
$('#ourinfo-content').innerHTML = `Failed to load: ${e}
`;
}
} else {
section.classList.add('hidden');
btn.textContent = 'Our Info';
}
});
// 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 ? `${escapeHtml(a.addresses[0])} ` : '';
return `${icon} ${label} ${addr}
`;
}).join('') : 'No discovered anchors
';
} 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 = `
Share My Details
${qrSvg}
${escapeHtml(connectString)}
Copy
Close
`;
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 = 'No identities
';
return;
}
list.innerHTML = identities.map(id => {
const active = id.isActive ? ' (active) ' : '';
const switchBtn = id.isActive ? '' : `Switch `;
const deleteBtn = id.isActive ? '' : `Delete `;
return `
${escapeHtml(id.displayName)} ${active}
${id.nodeId.substring(0, 16)}...
${switchBtn}${deleteBtn}
`;
}).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 = `Error: ${e}
`;
}
}
$('#create-identity-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
`;
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 = `
`;
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(); });
});
// --- Posting identities (personas) ---
// Cached list so the compose-box picker and settings list stay in sync.
let personasCache = [];
// 'all' (show everything) or a posting-id hex (filter to that persona).
let feedPersonaFilter = 'all';
// --- Release announcement / upgrade banner ---
async function loadUpgradeBanner() {
const banner = document.getElementById('upgrade-banner');
const titleEl = document.getElementById('upgrade-banner-title');
const bodyEl = document.getElementById('upgrade-banner-body');
const btn = document.getElementById('upgrade-banner-btn');
if (!banner || !titleEl || !bodyEl || !btn) return;
let channel = 'stable';
try { channel = await invoke('get_update_channel'); } catch (_) {}
let ann = null;
try { ann = await invoke('check_release_announcement', { channel }); } catch (_) { return; }
if (!ann) { banner.classList.add('hidden'); return; }
titleEl.textContent = ann.title || `v${ann.version} available`;
bodyEl.textContent = ann.body || `Upgrade to v${ann.version} (${ann.channel}).`;
btn.onclick = async () => {
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
catch (e) { window.open(ann.downloadUrl, '_blank'); }
};
banner.classList.remove('hidden');
}
async function loadUpdateSettings() {
const statusEl = document.getElementById('update-status');
let channel = 'stable';
try { channel = await invoke('get_update_channel'); } catch (_) {}
const radio = document.querySelector(`input[name="update-channel"][value="${channel}"]`);
if (radio) radio.checked = true;
document.querySelectorAll('input[name="update-channel"]').forEach(el => {
el.addEventListener('change', async () => {
try {
await invoke('set_update_channel', { channel: el.value });
loadUpgradeBanner().catch(() => {});
refreshUpdateStatus(el.value);
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
});
});
const checkBtn = document.getElementById('check-updates-btn');
if (checkBtn) {
checkBtn.onclick = async () => {
const ch = document.querySelector('input[name="update-channel"]:checked')?.value || 'stable';
await loadUpgradeBanner().catch(() => {});
refreshUpdateStatus(ch);
};
}
refreshUpdateStatus(channel);
}
async function refreshUpdateStatus(channel) {
const statusEl = document.getElementById('update-status');
if (!statusEl) return;
try {
const ann = await invoke('check_release_announcement', { channel });
if (ann) {
statusEl.innerHTML = `v${ann.version} available — Download `;
const link = document.getElementById('status-upgrade-link');
if (link) link.onclick = async (e) => {
e.preventDefault();
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
catch (_) { window.open(ann.downloadUrl, '_blank'); }
};
} else {
statusEl.textContent = `You're up to date on the ${channel} channel.`;
}
} catch (_) {
statusEl.textContent = '';
}
}
async function loadPersonas() {
try {
personasCache = await invoke('list_posting_identities') || [];
} catch (e) {
personasCache = [];
console.error('list_posting_identities:', e);
}
renderPersonasList();
renderComposePersonaPicker();
renderFeedPersonaFilter();
}
function renderFeedPersonaFilter() {
const row = $('#persona-filter-row');
if (!row) return;
if (personasCache.length < 2) {
row.classList.add('hidden');
row.innerHTML = '';
return;
}
row.classList.remove('hidden');
const pills = [{ id: 'all', label: 'All' }].concat(
personasCache.map(p => ({
id: p.nodeId,
label: p.displayName || p.nodeId.substring(0, 8),
}))
);
row.innerHTML = pills.map(p => {
const active = feedPersonaFilter === p.id;
const bg = active ? '#7fdbca' : '#2a2a3e';
const fg = active ? '#0a0a1f' : '#aaa';
return `${escapeHtml(p.label)} `;
}).join('');
row.querySelectorAll('.persona-pill').forEach(btn => {
btn.addEventListener('click', () => {
feedPersonaFilter = btn.dataset.id;
renderFeedPersonaFilter();
applyFeedPersonaFilter();
});
});
}
// Hide/show rendered post elements according to feedPersonaFilter.
function applyFeedPersonaFilter() {
const list = $('#feed-list');
if (!list) return;
const selected = feedPersonaFilter;
list.querySelectorAll('.post[data-author]').forEach(el => {
if (selected === 'all') { el.style.display = ''; return; }
const author = el.dataset.author || '';
const recipients = (el.dataset.recipients || '').split(',').filter(Boolean);
const matches = author === selected || recipients.includes(selected);
el.style.display = matches ? '' : 'none';
});
}
function renderPersonasList() {
const list = $('#personas-list');
if (!list) return;
if (personasCache.length === 0) {
list.innerHTML = 'No posting personas yet.
';
return;
}
list.innerHTML = personasCache.map(p => {
const label = p.displayName || '(unnamed)';
const defaultTag = p.isDefault ? ' (default) ' : '';
const setDefaultBtn = p.isDefault
? ''
: `Set default `;
const deleteBtn = p.isDefault
? ''
: `Delete `;
return `
${escapeHtml(label)}${defaultTag}
${p.nodeId.substring(0, 16)}...
${setDefaultBtn}${deleteBtn}
`;
}).join('');
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('set_default_posting_identity', { nodeIdHex: btn.dataset.id });
toast('Default persona updated (takes effect next restart)');
loadPersonas();
} catch (e) { toast('Error: ' + e); btn.disabled = false; }
});
});
list.querySelectorAll('.delete-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this persona? All keys for it will be lost. Past posts under it remain on the network.')) return;
try {
await invoke('delete_posting_identity', { nodeIdHex: btn.dataset.id });
toast('Persona deleted');
loadPersonas();
} catch (e) { toast('Error: ' + e); }
});
});
}
function renderComposePersonaPicker() {
const sel = $('#persona-select');
if (!sel) return;
// Only show when there are 2+ personas; otherwise pointless.
if (personasCache.length < 2) {
sel.classList.add('hidden');
sel.innerHTML = '';
return;
}
sel.classList.remove('hidden');
const prior = sel.value;
const options = personasCache.map(p => {
const label = p.displayName || p.nodeId.substring(0, 8);
const tag = p.isDefault ? ' (default)' : '';
return `${escapeHtml(label)}${tag} `;
}).join('');
sel.innerHTML = options;
// Default to the stored default; preserve user selection across reloads.
const defaultEntry = personasCache.find(p => p.isDefault);
const defaultValue = defaultEntry ? defaultEntry.nodeId : personasCache[0].nodeId;
sel.value = (prior && personasCache.some(p => p.nodeId === prior)) ? prior : defaultValue;
}
$('#create-persona-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
const nameInput = overlay.querySelector('#new-persona-name');
nameInput.focus();
overlay.querySelector('#new-persona-create').addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
try {
await invoke('create_posting_identity', { displayName: name });
toast(`Persona created: ${name}`);
overlay.remove();
loadPersonas();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#new-persona-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') overlay.querySelector('#new-persona-create').click();
});
});
// Export wizard
$('#export-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
overlay.querySelector('#export-go').addEventListener('click', async () => {
const scope = overlay.querySelector('input[name="export-scope"]:checked')?.value;
if (!scope) { toast('Select a scope'); return; }
let outputDir = overlay.querySelector('#export-output-dir').value.trim() || 'Downloads';
// Resolve relative to home
if (!outputDir.startsWith('/')) {
// On desktop, use a reasonable default
outputDir = outputDir;
}
const status = overlay.querySelector('#export-status');
status.textContent = 'Exporting...';
overlay.querySelector('#export-go').disabled = true;
try {
const result = await invoke('export_data', { scope, outputDir });
status.textContent = result;
// On mobile: extract file path from result and offer to save via SAF
const pathMatch = result.match(/:\s*(.+\.zip)/);
if (pathMatch) {
try {
status.textContent = 'Saving to device...';
const shareResult = await invoke('share_file', { filePath: pathMatch[1], mimeType: 'application/zip' });
status.textContent = shareResult === 'Cancelled' ? 'Export saved internally. ' + result : shareResult;
} catch (shareErr) {
// share_file not available (desktop) or failed — that's ok, file is in app dir
status.textContent = result;
}
}
toast('Export complete!');
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Export failed: ' + e);
} finally {
overlay.querySelector('#export-go').disabled = false;
}
});
overlay.querySelector('#export-browse').addEventListener('click', async () => {
try {
const path = await invoke('pick_folder', { title: 'Choose export folder' });
if (path) overlay.querySelector('#export-output-dir').value = path;
} catch (_) {}
});
overlay.querySelector('#export-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Import wizard
$('#import-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
Import Data
Select an ItsGoin export ZIP file.
Browse
Preview
Import
Cancel
`;
document.body.appendChild(overlay);
overlay.querySelector('#import-browse').addEventListener('click', async () => {
try {
const path = await invoke('pick_file', { title: 'Select export ZIP', filterName: 'ZIP files', filterExt: ['zip'] });
if (path) overlay.querySelector('#import-zip-path').value = path;
} catch (_) {}
});
overlay.querySelector('#import-preview').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
if (!zipPath) { toast('Enter a ZIP path'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Reading...';
try {
const summaryJson = await invoke('import_summary', { zipPath });
const s = JSON.parse(summaryJson);
const box_ = overlay.querySelector('#import-summary-box');
box_.style.display = 'block';
box_.innerHTML = `
Node: ${s.node_id.substring(0, 16)}...
Posts: ${s.post_count} Blobs: ${s.blob_count}
Has key: ${s.has_identity_key ? 'Yes' : 'No'} Follows: ${s.has_follows ? 'Yes' : 'No'}
Exported: ${new Date(s.export_date).toLocaleDateString()}
`;
overlay.querySelector('#import-action-box').style.display = 'block';
overlay.querySelector('#import-go').style.display = '';
// Hide "add as identity" if no key in export
if (!s.has_identity_key) {
const addIdRadio = overlay.querySelector('input[value="add_identity"]');
addIdRadio.disabled = true;
addIdRadio.parentElement.style.opacity = '0.4';
}
status.textContent = '';
} catch (e) {
status.textContent = 'Error: ' + e;
}
});
// Show/hide merge key input based on selected action
overlay.querySelectorAll('input[name="import-action"]').forEach(radio => {
radio.addEventListener('change', () => {
const mergeInput = overlay.querySelector('#merge-key-input');
mergeInput.style.display = radio.value === 'merge_key' && radio.checked ? 'block' : 'none';
});
});
overlay.querySelector('#import-go').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
if (!action) { toast('Select an import action'); return; }
const status = overlay.querySelector('#import-status');
status.textContent = 'Importing...';
overlay.querySelector('#import-go').disabled = true;
try {
let result;
if (action === 'as_personas') {
result = await invoke('import_as_personas_cmd', { zipPath });
} else if (action === 'add_identity') {
result = await invoke('import_as_new_identity', { zipPath });
} else if (action === 'merge_key') {
const keyHex = overlay.querySelector('#import-merge-key').value.trim();
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); overlay.querySelector('#import-go').disabled = false; return; }
result = await invoke('import_merge_with_key', { zipPath, keyHex });
} else {
result = await invoke('import_public_posts', { zipPath });
}
status.textContent = result;
toast('Import complete!');
} catch (e) {
status.textContent = 'Error: ' + e;
toast('Import failed: ' + e);
} finally {
overlay.querySelector('#import-go').disabled = false;
}
});
overlay.querySelector('#import-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#notifications-btn').addEventListener('click', async () => {
// Load current settings
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
const postVal = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
const reactVal = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
const nearbyVal = await invoke('get_setting', { key: 'notif_nearby' }).catch(() => null) || 'on';
function btnGroup(label, key, value, options) {
const btns = options.map(([v, text]) =>
`${text} `
).join('');
return ``;
}
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 & Comments', 'notif_reacts', reactVal, [['off','Off'],['on','On']])}
${btnGroup('Nearby Users', 'notif_nearby', nearbyVal, [['off','Off'],['on','On']])}
Changes are saved automatically.
`;
openPopover('Notifications', html, {
onOpen() {
document.querySelectorAll('.notif-opt').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.key;
const value = btn.dataset.value;
// Update active state
btn.closest('.notif-opts').querySelectorAll('.notif-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
await invoke('set_setting', { key, value });
toast('Setting saved');
});
});
}
});
});
resetDataBtn.addEventListener('click', async () => {
if (!confirm('This will delete all posts, peers, and settings. Your identity key will be preserved. Continue?')) return;
if (!confirm('Are you sure? This cannot be undone.')) return;
resetDataBtn.disabled = true;
try {
const result = await invoke('reset_data');
toast(result);
resetDataBtn.textContent = 'Restart app to apply';
} catch (e) {
toast('Error: ' + e);
resetDataBtn.disabled = false;
}
});
setupBtn.addEventListener('click', doSetupName);
setupName.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSetupName();
});
// --- Init ---
async function init() {
updateCharCount();
_lastFeedViewMs = Date.now();
updateNetworkIndicator().catch(() => {});
// Welcome screen — stagger count reveals every 2 seconds
let _welcomeTick = 0;
let _welcomeValues = [0, 0, 0, 0, 0];
const welcomeFields = ['welcome-connections', 'welcome-posts', 'welcome-messages', 'welcome-reacts', 'welcome-comments'];
// Fetch data in background (non-blocking — updates _welcomeValues + tab badges + notifications)
const welcomeFetch = () => {
invoke('get_network_summary').then(info => {
_welcomeValues[0] = info.totalConnections || 0;
}).catch(() => {});
invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }).then(b => {
_welcomeValues[1] = b.newFeed || 0;
_welcomeValues[2] = b.unreadMessages || 0;
_welcomeValues[3] = b.newReacts || 0;
_welcomeValues[4] = b.newComments || 0;
// Update tab badges from welcome screen
updateTabBadge('feed', b.newFeed || 0);
updateTabBadge('myposts', b.newEngagement || 0);
updateTabBadge('messages', b.unreadMessages || 0);
// Ticker + notifications only after user leaves welcome screen
// (welcome page already shows these counts directly)
}).catch(() => {});
};
// Stagger reveals — one field every 2 seconds (first fetch happens on first tick)
let _welcomeRevealed = 0;
const welcomeInterval = setInterval(() => {
if (currentTab !== 'welcome') {
clearInterval(welcomeInterval);
return;
}
// Reveal next field
if (_welcomeRevealed < welcomeFields.length) {
const el = document.getElementById(welcomeFields[_welcomeRevealed]);
if (el) el.textContent = _welcomeValues[_welcomeRevealed];
_welcomeRevealed++;
}
// Update all revealed fields with latest data
welcomeFetch();
for (let i = 0; i < _welcomeRevealed; i++) {
const el = document.getElementById(welcomeFields[i]);
if (el) el.textContent = _welcomeValues[i];
}
}, 2000);
// Ready button — click to go to feed
const readyBtn = document.getElementById('welcome-ready-btn');
const readyBar = document.getElementById('welcome-ready-bar');
if (readyBtn) {
readyBtn.addEventListener('click', () => {
if (readyBtn.disabled) return;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const feedTab = document.querySelector('.tab[data-tab="feed"]');
if (feedTab) feedTab.classList.add('active');
document.getElementById('view-feed').classList.add('active');
currentTab = 'feed';
loadFeed(true);
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
});
}
// Wait for backend in the background, then load node info
(async () => {
for (let attempt = 0; attempt < 30; attempt++) {
try {
// Animate progress bar toward 90% during readiness checks
if (readyBar) readyBar.style.width = Math.min(90, (attempt + 1) * 3) + '%';
await invoke('get_node_info');
break;
} catch (e) {
if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; }
await new Promise(r => setTimeout(r, 300));
}
}
// Load personas up front so the compose-box picker is ready before
// the user opens the Feed tab.
loadPersonas().catch(() => {});
// Check for a newer release on the user's selected channel and,
// if found, surface the upgrade banner on the welcome screen.
loadUpgradeBanner().catch(() => {});
const info = await loadNodeInfo();
// Show first-run chooser only if no profile AND only one identity (auto-created)
let isFirstRun = false;
if (info && !info.hasProfile) {
try {
const ids = await invoke('list_identities');
isFirstRun = ids.length <= 1;
} catch (_) {}
}
if (isFirstRun) {
// First-run: start fresh or import
const chooser = document.createElement('div');
chooser.className = 'image-lightbox';
chooser.style.cursor = 'default';
chooser.innerHTML = `
Welcome to ItsGoin
How would you like to get started?
Start Fresh
Import an Identity
`;
document.body.appendChild(chooser);
chooser.querySelector('#first-run-new').addEventListener('click', () => {
chooser.remove();
setupOverlay.classList.remove('hidden');
setupName.focus();
});
chooser.querySelector('#first-run-import').addEventListener('click', () => {
chooser.remove();
// Open the import wizard
document.getElementById('import-btn')?.click();
});
} else if (info && !info.hasProfile) {
// Not first run, but no profile set — show name setup
setupOverlay.classList.remove('hidden');
setupName.focus();
}
// Pre-load messages (lightweight) — feed loads when user switches to it
loadMessages(true).catch(() => {});
// Mark ready button as clickable immediately — feed loads on tab switch
if (readyBar) readyBar.style.width = '100%';
if (readyBtn) {
readyBtn.disabled = false;
readyBtn.textContent = 'Ready — Go to Feed';
readyBtn.style.opacity = '1';
readyBtn.style.color = '#7fdbca';
readyBtn.style.borderColor = '#7fdbca';
readyBtn.style.cursor = 'pointer';
}
})();
// Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam)
setTimeout(() => { _notifReady = true; }, 6000);
// Auto-refresh every 10 seconds — only the active tab
const _initTime = Date.now();
let _duplicateWarningShown = false;
setInterval(async () => {
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();
// Check for duplicate identity (set by anchor during bootstrap)
if (!_duplicateWarningShown) {
try {
const info = await invoke('get_node_info');
if (info && info.duplicateDetected) {
_duplicateWarningShown = true;
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#c0392b;color:#fff;padding:0.5rem;text-align:center;font-size:0.8rem;z-index:999;display:flex;align-items:center;justify-content:center;gap:0.5rem;';
banner.innerHTML = 'This identity may be active on another device. Sync paused. Continue Anyway ';
document.body.prepend(banner);
banner.querySelector('#dup-override-btn').addEventListener('click', async () => {
try { await invoke('clear_duplicate_flag'); } catch (_) {}
banner.remove();
toast('Sync resumed');
});
}
} catch (_) {}
}
}, 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();