v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts propagate only through the CDN (pull + header-diff neighbor propagation). Encrypted posts propagate only through pull with merged author-or-recipient match. There is no remaining sender→recipient traffic correlation signal on the wire for content. Protocol (network-breaking): - Retire MessageType 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are deleted along with the handlers and senders. - SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender functions targeting audience are deleted; the existing handlers stay (both already dead code on the send side). Core removals: - `push_to_audience`, `notify_post`, `push_delete`, `push_disconnect_to_audience`, `push_address_update_to_audience`, `send_audience_request`, `send_audience_response`, `send_to_audience` — all gone from network.rs. - `handle_post_notification` removed from connection.rs. - `request_audience`, `approve_audience`, `deny_audience`, `remove_audience`, `list_audience_members`, `list_audience` removed from Node. - `audience_pushed` step removed from post creation. - `AudienceDirection`, `AudienceStatus`, `AudienceRecord`, `AudienceApprovalMode` removed from types. - Storage: `store_audience`, `list_audience`, `list_audience_members`, `remove_audience`, `row_to_audience_record`, `audience_crud` test, the `audience` CREATE TABLE, and the audience-dependent social route rebuild branch all removed. Upgraded DBs retain the orphan `audience` table; nothing touches it. Follow-on cleanups: - `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`. The Display/FromStr impl accepts legacy "audience"/"mutual" strings from pre-v0.6.2 DBs and maps them to Follow. - Blob-eviction priority function drops the audience factor; relationship is now own-author vs followed vs other. Tests updated accordingly. - `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the author's public follows (`list_public_follows`) rather than a separate audience table. `ModerationMode::AudienceOnly` similarly renamed. - Follow/unfollow routines simplified: no audience downgrade logic; unfollow removes the social route entirely. UI: - CLI: `audience*` commands removed. - Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`, `request_audience`, `approve_audience`, `remove_audience` commands removed from invoke_handler. Frontend: audience panel and audience/mutual badges removed; compose permission dropdown shows "Followers" instead of "Audience"; `loadAudience` is a no-op stub that hides any leftover DOM. Tests: 111 / 111 core tests pass. Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete propagation, visibility updates, direct post push, post notifications, or audience requests. Upgrade both ends.
4047 lines
182 KiB
JavaScript
4047 lines
182 KiB
JavaScript
// Tauri v2 with withGlobalTauri: true injects window.__TAURI__
|
|
const { invoke } = window.__TAURI__.core;
|
|
|
|
// --- DOM refs ---
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const setupOverlay = $('#setup-overlay');
|
|
const setupName = $('#setup-name');
|
|
const setupBtn = $('#setup-btn');
|
|
const nodeIdEl = $('#node-id');
|
|
const copyBtn = $('#copy-connect');
|
|
const postContent = $('#post-content');
|
|
const postBtn = $('#post-btn');
|
|
const charCount = $('#char-count');
|
|
const feedList = $('#feed-list');
|
|
const myPostsList = $('#my-posts-list');
|
|
const conversationsList = $('#conversations-list');
|
|
const messageRequestsList = $('#message-requests-list');
|
|
const messageRequestsSection = $('#message-requests-section');
|
|
const connectInput = $('#connect-input');
|
|
const connectBtn = $('#connect-btn');
|
|
const connectStatus = $('#connect-status');
|
|
let peersList = null; // created dynamically inside diagnostics popover
|
|
const followsList = $('#follows-list');
|
|
// suggestedList removed — Suggested Peers section removed from UI
|
|
const syncBtn = $('#sync-btn');
|
|
const exportKeyBtn = $('#export-key-btn');
|
|
const visibilitySelect = $('#visibility-select');
|
|
const circleSelect = $('#circle-select');
|
|
const circleNameInput = $('#circle-name-input');
|
|
const createCircleBtn = $('#create-circle-btn');
|
|
const circlesList = $('#circles-list');
|
|
// Stats bar removed — contextual counts shown in tab badges
|
|
const toastEl = $('#toast');
|
|
const profileNameInput = $('#profile-name');
|
|
const profileBioInput = $('#profile-bio');
|
|
const saveProfileBtn = $('#save-profile-btn');
|
|
const redundancyPanel = $('#redundancy-panel');
|
|
const dmRecipientSelect = $('#dm-recipient-select');
|
|
const dmContent = $('#dm-content');
|
|
const dmSendBtn = $('#dm-send-btn');
|
|
const anchorsList = $('#anchors-list');
|
|
const anchorAddSelect = null; // removed — anchors are read-only
|
|
const anchorAddBtn = null;
|
|
const attachBtn = $('#attach-btn');
|
|
const fileInput = $('#file-input');
|
|
const attachmentPreview = $('#attachment-preview');
|
|
const audiencePendingList = $('#audience-pending-list');
|
|
const audienceApprovedList = $('#audience-approved-list');
|
|
let connectionsList = null; // created dynamically inside diagnostics popover
|
|
let networkSummaryEl = null; // created dynamically inside diagnostics popover
|
|
const resetDataBtn = $('#reset-data-btn');
|
|
|
|
// --- State ---
|
|
let currentTab = 'welcome';
|
|
let connectString = '';
|
|
let myNodeId = '';
|
|
const POST_MAX_CHARS = 500;
|
|
let selectedFiles = []; // { data: ArrayBuffer, mime: string, name: string }
|
|
let diagnosticsInterval = null;
|
|
let lastDiagUpdate = null;
|
|
// Cache fingerprints to avoid destructive reloads when data hasn't changed
|
|
let _feedFingerprint = '';
|
|
let _myPostsFingerprint = '';
|
|
let _messagesFingerprint = '';
|
|
let _lastMsgTimestamp = 0; // Track newest message timestamp for tiered polling
|
|
let _peopleFingerprint = '';
|
|
|
|
// --- Identicon generator ---
|
|
// Generates a deterministic 5x5 mirrored SVG identicon from a hex node_id.
|
|
function generateIdenticon(hexId, size = 20) {
|
|
const bytes = [];
|
|
const hex = hexId.replace(/[^0-9a-fA-F]/g, '');
|
|
for (let i = 0; i < 16 && i * 2 + 1 < hex.length; i++) {
|
|
bytes.push(parseInt(hex.substring(i * 2, i * 2 + 2), 16));
|
|
}
|
|
while (bytes.length < 16) bytes.push(0);
|
|
|
|
const hue = ((bytes[0] << 8) | bytes[1]) % 360;
|
|
const sat = 55 + (bytes[2] % 25);
|
|
const lit = 55 + (bytes[3] % 20);
|
|
const fg = `hsl(${hue}, ${sat}%, ${lit}%)`;
|
|
const bg = `hsl(${hue}, ${sat}%, ${lit * 0.25}%)`;
|
|
|
|
const cells = [];
|
|
for (let row = 0; row < 5; row++) {
|
|
for (let col = 0; col < 3; col++) {
|
|
const byteIdx = 4 + row * 3 + col;
|
|
const on = byteIdx < bytes.length ? (bytes[byteIdx] & 1) === 1 : false;
|
|
if (on) {
|
|
const cellSize = size / 5;
|
|
cells.push(`<rect x="${col * cellSize}" y="${row * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`);
|
|
const mirrorCol = 4 - col;
|
|
if (mirrorCol !== col) {
|
|
cells.push(`<rect x="${mirrorCol * cellSize}" y="${row * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return `<svg class="identicon" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bg}" rx="2"/>${cells.join('')}</svg>`;
|
|
}
|
|
|
|
// --- QR Code SVG generator ---
|
|
// Minimal QR code encoder for alphanumeric data (connect strings).
|
|
// Supports versions 1-6, error correction level L.
|
|
function generateQRCodeSVG(text, pixelSize) {
|
|
// GF(256) arithmetic for Reed-Solomon
|
|
const GF_EXP = new Uint8Array(512);
|
|
const GF_LOG = new Uint8Array(256);
|
|
{
|
|
let v = 1;
|
|
for (let i = 0; i < 255; i++) {
|
|
GF_EXP[i] = v;
|
|
GF_LOG[v] = i;
|
|
v <<= 1;
|
|
if (v >= 256) v ^= 0x11d;
|
|
}
|
|
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
|
|
}
|
|
function gfMul(a, b) { return a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]; }
|
|
|
|
function rsGenPoly(nsym) {
|
|
let g = [1];
|
|
for (let i = 0; i < nsym; i++) {
|
|
const ng = new Array(g.length + 1).fill(0);
|
|
for (let j = 0; j < g.length; j++) {
|
|
ng[j] ^= g[j];
|
|
ng[j + 1] ^= gfMul(g[j], GF_EXP[i]);
|
|
}
|
|
g = ng;
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function rsEncode(data, nsym) {
|
|
const gen = rsGenPoly(nsym);
|
|
const out = new Uint8Array(data.length + nsym);
|
|
out.set(data);
|
|
for (let i = 0; i < data.length; i++) {
|
|
const coef = out[i];
|
|
if (coef !== 0) {
|
|
for (let j = 0; j < gen.length; j++) {
|
|
out[i + j] ^= gfMul(gen[j], coef);
|
|
}
|
|
}
|
|
}
|
|
return out.slice(data.length);
|
|
}
|
|
|
|
// Alphanumeric encoding
|
|
const ALNUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
|
|
function encodeAlphanumeric(str) {
|
|
const bits = [];
|
|
function pushBits(val, len) { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); }
|
|
const upper = str.toUpperCase();
|
|
// Determine version: data capacity for alphanumeric, EC level L
|
|
// version: [totalCodewords, ecCodewords, dataCapacityAlphanumeric]
|
|
const versions = [
|
|
null,
|
|
[26, 7, 25], // v1
|
|
[44, 10, 47], // v2
|
|
[70, 15, 77], // v3
|
|
[100, 20, 114], // v4
|
|
[134, 26, 154], // v5
|
|
[172, 36, 195], // v6
|
|
];
|
|
let ver = 0;
|
|
for (let v = 1; v <= 6; v++) {
|
|
if (upper.length <= versions[v][2]) { ver = v; break; }
|
|
}
|
|
if (ver === 0) throw new Error('Text too long for QR v1-6');
|
|
|
|
const charCountBits = ver <= 9 ? 9 : 13;
|
|
pushBits(0b0010, 4); // mode: alphanumeric
|
|
pushBits(upper.length, charCountBits);
|
|
|
|
for (let i = 0; i < upper.length; i += 2) {
|
|
const a = ALNUM.indexOf(upper[i]);
|
|
if (i + 1 < upper.length) {
|
|
const b = ALNUM.indexOf(upper[i + 1]);
|
|
pushBits(a * 45 + b, 11);
|
|
} else {
|
|
pushBits(a, 6);
|
|
}
|
|
}
|
|
|
|
const [totalCW, ecCW] = versions[ver];
|
|
const dataCW = totalCW - ecCW;
|
|
const totalDataBits = dataCW * 8;
|
|
|
|
// Terminator
|
|
const remaining = totalDataBits - bits.length;
|
|
const termLen = Math.min(4, remaining);
|
|
for (let i = 0; i < termLen; i++) bits.push(0);
|
|
// Byte-align
|
|
while (bits.length % 8 !== 0) bits.push(0);
|
|
// Pad bytes
|
|
const pads = [0xEC, 0x11];
|
|
let pi = 0;
|
|
while (bits.length < totalDataBits) {
|
|
for (let b = 7; b >= 0; b--) bits.push((pads[pi] >> b) & 1);
|
|
pi = (pi + 1) % 2;
|
|
}
|
|
|
|
// Convert to bytes
|
|
const dataBytes = new Uint8Array(dataCW);
|
|
for (let i = 0; i < dataCW; i++) {
|
|
let v2 = 0;
|
|
for (let b = 0; b < 8; b++) v2 = (v2 << 1) | (bits[i * 8 + b] || 0);
|
|
dataBytes[i] = v2;
|
|
}
|
|
|
|
const ecBytes = rsEncode(dataBytes, ecCW);
|
|
return { ver, dataBytes, ecBytes, totalCW, ecCW };
|
|
}
|
|
|
|
const { ver, dataBytes, ecBytes } = encodeAlphanumeric(text);
|
|
const size = 17 + ver * 4;
|
|
const grid = Array.from({ length: size }, () => new Uint8Array(size));
|
|
const reserved = Array.from({ length: size }, () => new Uint8Array(size));
|
|
|
|
function setModule(r, c, val) {
|
|
if (r >= 0 && r < size && c >= 0 && c < size) {
|
|
grid[r][c] = val ? 1 : 0;
|
|
reserved[r][c] = 1;
|
|
}
|
|
}
|
|
|
|
// Finder patterns
|
|
function drawFinder(row, col) {
|
|
for (let dr = -1; dr <= 7; dr++) {
|
|
for (let dc = -1; dc <= 7; dc++) {
|
|
const r = row + dr, c = col + dc;
|
|
if (r < 0 || r >= size || c < 0 || c >= size) continue;
|
|
const inOuter = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
|
const inInner = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4;
|
|
const onBorder = dr === 0 || dr === 6 || dc === 0 || dc === 6;
|
|
const val = inInner || (inOuter && onBorder);
|
|
setModule(r, c, val);
|
|
}
|
|
}
|
|
}
|
|
drawFinder(0, 0);
|
|
drawFinder(0, size - 7);
|
|
drawFinder(size - 7, 0);
|
|
|
|
// Timing patterns
|
|
for (let i = 8; i < size - 8; i++) {
|
|
setModule(6, i, i % 2 === 0);
|
|
setModule(i, 6, i % 2 === 0);
|
|
}
|
|
|
|
// Dark module
|
|
setModule(size - 8, 8, 1);
|
|
|
|
// Reserve format info areas
|
|
for (let i = 0; i < 8; i++) {
|
|
if (!reserved[8][i]) { reserved[8][i] = 1; }
|
|
if (!reserved[i][8]) { reserved[i][8] = 1; }
|
|
if (!reserved[8][size - 1 - i]) { reserved[8][size - 1 - i] = 1; }
|
|
if (!reserved[size - 1 - i][8]) { reserved[size - 1 - i][8] = 1; }
|
|
}
|
|
reserved[8][8] = 1;
|
|
|
|
// Alignment pattern for v2+
|
|
if (ver >= 2) {
|
|
const alignPos = [null, null, [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]][ver];
|
|
for (const ar of alignPos) {
|
|
for (const ac of alignPos) {
|
|
if (reserved[ar][ac]) continue;
|
|
for (let dr = -2; dr <= 2; dr++) {
|
|
for (let dc = -2; dc <= 2; dc++) {
|
|
const val = Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0);
|
|
setModule(ar + dr, ac + dc, val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Place data bits
|
|
const allBytes = new Uint8Array(dataBytes.length + ecBytes.length);
|
|
allBytes.set(dataBytes);
|
|
allBytes.set(ecBytes, dataBytes.length);
|
|
|
|
const dataBits = [];
|
|
for (const b of allBytes) {
|
|
for (let i = 7; i >= 0; i--) dataBits.push((b >> i) & 1);
|
|
}
|
|
|
|
let bitIdx = 0;
|
|
let upward = true;
|
|
for (let col = size - 1; col >= 0; col -= 2) {
|
|
if (col === 6) col = 5; // skip timing column
|
|
const rows = upward ? Array.from({ length: size }, (_, i) => size - 1 - i) : Array.from({ length: size }, (_, i) => i);
|
|
for (const row of rows) {
|
|
for (const dc of [0, -1]) {
|
|
const c = col + dc;
|
|
if (c < 0 || c >= size) continue;
|
|
if (reserved[row][c]) continue;
|
|
grid[row][c] = bitIdx < dataBits.length ? dataBits[bitIdx] : 0;
|
|
bitIdx++;
|
|
}
|
|
}
|
|
upward = !upward;
|
|
}
|
|
|
|
// Apply mask 0 (checkerboard: (row + col) % 2 === 0)
|
|
for (let r = 0; r < size; r++) {
|
|
for (let c = 0; c < size; c++) {
|
|
if (!reserved[r][c] && (r + c) % 2 === 0) {
|
|
grid[r][c] ^= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format info for EC level L (01), mask 0 (000) → format bits = 0b01000
|
|
// Pre-computed with BCH: 0x77c0 → bits: 111011111000010
|
|
const formatBits = [1,1,1,0,1,1,1,1,1,0,0,0,0,1,0];
|
|
// Place format info
|
|
const formatPositions1 = [[0,8],[1,8],[2,8],[3,8],[4,8],[5,8],[7,8],[8,8],[8,7],[8,5],[8,4],[8,3],[8,2],[8,1],[8,0]];
|
|
const formatPositions2 = [[8,size-1],[8,size-2],[8,size-3],[8,size-4],[8,size-5],[8,size-6],[8,size-7],[size-7,8],[size-6,8],[size-5,8],[size-4,8],[size-3,8],[size-2,8],[size-1,8]];
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
const bit = formatBits[i];
|
|
const [r1, c1] = formatPositions1[i];
|
|
grid[r1][c1] = bit;
|
|
if (i < formatPositions2.length) {
|
|
const [r2, c2] = formatPositions2[i];
|
|
grid[r2][c2] = bit;
|
|
}
|
|
}
|
|
|
|
// Render SVG
|
|
const moduleSize = Math.max(1, Math.floor(pixelSize / (size + 8))); // +8 for quiet zone
|
|
const totalSize = (size + 8) * moduleSize;
|
|
let rects = '';
|
|
for (let r = 0; r < size; r++) {
|
|
for (let c = 0; c < size; c++) {
|
|
if (grid[r][c]) {
|
|
rects += `<rect x="${(c + 4) * moduleSize}" y="${(r + 4) * moduleSize}" width="${moduleSize}" height="${moduleSize}"/>`;
|
|
}
|
|
}
|
|
}
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalSize}" height="${totalSize}" viewBox="0 0 ${totalSize} ${totalSize}">
|
|
<rect width="${totalSize}" height="${totalSize}" fill="#fff" rx="4"/>
|
|
<g fill="#000">${rects}</g>
|
|
</svg>`;
|
|
}
|
|
|
|
// --- Helpers ---
|
|
function toast(msg) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.remove('hidden');
|
|
setTimeout(() => toastEl.classList.add('hidden'), 3000);
|
|
}
|
|
|
|
// --- Notifications (Tauri plugin) ---
|
|
let _notifReady = false;
|
|
let _activeNotificationIds = new Set();
|
|
|
|
async function maybeNotify(title, body, tag) {
|
|
try {
|
|
showTicker(title);
|
|
// Try Tauri plugin first, then Web Notification API, then invoke fallback
|
|
let sent = false;
|
|
const tauriNotif = window.__TAURI__?.notification || window.__TAURI__?.plugin?.notification;
|
|
if (tauriNotif) {
|
|
try {
|
|
const { isPermissionGranted, requestPermission, sendNotification } = tauriNotif;
|
|
let granted = await isPermissionGranted();
|
|
if (!granted) {
|
|
const perm = await requestPermission();
|
|
granted = perm === 'granted';
|
|
}
|
|
if (granted) {
|
|
sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined });
|
|
if (tag) _activeNotificationIds.add(tag);
|
|
sent = true;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
if (!sent && 'Notification' in window && Notification.permission !== 'denied') {
|
|
try {
|
|
if (Notification.permission === 'default') await Notification.requestPermission();
|
|
if (Notification.permission === 'granted') {
|
|
new Notification(title, { body, tag, silent: false });
|
|
sent = true;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
if (!sent) {
|
|
// Last resort: try invoke to Rust-side notification
|
|
try {
|
|
await invoke('send_notification', { title, body });
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function clearNotifications(tagPrefix) {
|
|
try {
|
|
if (window.__TAURI__?.notification) {
|
|
const { removeActive } = window.__TAURI__.notification;
|
|
if (!removeActive) return;
|
|
const toRemove = [..._activeNotificationIds].filter(t => t.startsWith(tagPrefix));
|
|
for (const tag of toRemove) {
|
|
try {
|
|
await removeActive({ notifications: [{ id: hashCode(tag) }] });
|
|
} catch (_) {}
|
|
_activeNotificationIds.delete(tag);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
function hashCode(str) {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const ch = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + ch;
|
|
hash |= 0;
|
|
}
|
|
return Math.abs(hash);
|
|
}
|
|
|
|
// --- Popover helpers ---
|
|
let popoverOnClose = null;
|
|
function openPopover(title, html, opts = {}) {
|
|
const overlay = $('#popover-overlay');
|
|
$('#popover-title').textContent = title;
|
|
$('#popover-body').innerHTML = html;
|
|
overlay.classList.remove('hidden');
|
|
if (opts.onOpen) opts.onOpen();
|
|
popoverOnClose = opts.onClose || null;
|
|
}
|
|
function closePopover() {
|
|
const overlay = $('#popover-overlay');
|
|
overlay.classList.add('hidden');
|
|
$('#popover-body').innerHTML = '';
|
|
if (diagnosticsInterval) { clearInterval(diagnosticsInterval); diagnosticsInterval = null; }
|
|
if (activityInterval) { clearInterval(activityInterval); activityInterval = null; }
|
|
// Clear dynamic refs from popover content
|
|
networkSummaryEl = null; connectionsList = null; peersList = null;
|
|
if (popoverOnClose) { popoverOnClose(); popoverOnClose = null; }
|
|
}
|
|
$('#popover-close-btn').addEventListener('click', closePopover);
|
|
$('#popover-overlay').addEventListener('click', (e) => {
|
|
if (e.target === $('#popover-overlay')) closePopover();
|
|
});
|
|
|
|
function relativeTime(timestampMs) {
|
|
const now = Date.now();
|
|
const diff = now - timestampMs;
|
|
const secs = Math.floor(diff / 1000);
|
|
if (secs < 60) return 'just now';
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return 'yesterday';
|
|
if (days < 7) return `${days}d ago`;
|
|
return new Date(timestampMs).toLocaleDateString();
|
|
}
|
|
|
|
function peerLabel(nodeId, displayName) {
|
|
if (displayName) return `${displayName} (${nodeId.substring(0, 6)})`;
|
|
return nodeId.substring(0, 12) + '...';
|
|
}
|
|
|
|
function renderPost(post, index) {
|
|
const authorShort = post.author.substring(0, 12);
|
|
const authorName = post.authorName || authorShort;
|
|
const authorClass = post.isMe ? 'author-me' : '';
|
|
const personaTag = post.asPersona
|
|
? ` <span style="color:#7fdbca;font-size:0.7rem">as ${escapeHtml(post.asPersona)}</span>`
|
|
: '';
|
|
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 = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
|
} else if (post.visibility === 'encrypted') {
|
|
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
|
}
|
|
|
|
let displayContent;
|
|
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
|
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
|
} else if (post.decryptedContent) {
|
|
displayContent = escapeHtml(post.decryptedContent);
|
|
} else {
|
|
displayContent = escapeHtml(post.content);
|
|
}
|
|
|
|
const deleteBtn = post.isMe
|
|
? `<button class="delete-post-btn" data-post-id="${post.id}" title="Delete post">x</button>`
|
|
: '';
|
|
|
|
let attachmentsHtml = '';
|
|
if (post.attachments && post.attachments.length > 0) {
|
|
const imgs = post.attachments.filter(a => a.mimeType.startsWith('image/'));
|
|
const vids = post.attachments.filter(a => a.mimeType.startsWith('video/'));
|
|
const auds = post.attachments.filter(a => a.mimeType.startsWith('audio/'));
|
|
const others = post.attachments.filter(a => !a.mimeType.startsWith('image/') && !a.mimeType.startsWith('video/') && !a.mimeType.startsWith('audio/'));
|
|
let inner = '';
|
|
for (const a of imgs) {
|
|
inner += `<img class="post-image" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" alt="attachment" />`;
|
|
}
|
|
for (const a of vids) {
|
|
inner += `<div class="video-wrap">
|
|
<video class="post-video" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" controls preload="none"></video>
|
|
<div class="video-controls"><button class="video-download" data-cid="${a.cid}" data-mime="${escapeHtml(a.mimeType)}" title="Download video">Download</button></div>
|
|
</div>`;
|
|
}
|
|
for (const a of auds) {
|
|
inner += `<audio class="post-audio" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" controls preload="none"></audio>`;
|
|
}
|
|
for (const a of others) {
|
|
const sizeKb = Math.round(a.sizeBytes / 1024);
|
|
const ext = a.mimeType.split('/').pop() || 'file';
|
|
inner += `<button class="post-file file-download" data-cid="${a.cid}" data-post-id="${post.id}" data-mime="${escapeHtml(a.mimeType)}" data-ext="${escapeHtml(ext)}" title="Download">${escapeHtml(ext.toUpperCase())} (${sizeKb} KB)</button>`;
|
|
}
|
|
attachmentsHtml = `<div class="post-attachments">${inner}</div>`;
|
|
}
|
|
|
|
const authorLink = post.isMe
|
|
? `<span class="${authorClass}">${escapeHtml(authorName)}${meTag}</span>`
|
|
: `<a class="post-author-link" data-node-id="${post.author}" title="View in People tab">${escapeHtml(authorName)}</a>`;
|
|
|
|
// Engagement bar: reactions (top 5 + total) + comments
|
|
let reactionsHtml = '';
|
|
if (post.reactionCounts && post.reactionCounts.length > 0) {
|
|
const sorted = [...post.reactionCounts].sort((a, b) => b.count - a.count);
|
|
const top5 = sorted.slice(0, 5);
|
|
const totalResponses = sorted.reduce((sum, r) => sum + r.count, 0);
|
|
const pills = top5.map(r =>
|
|
`<button class="reaction-pill${r.reactedByMe ? ' reacted' : ''}" data-post-id="${post.id}" data-emoji="${escapeHtml(r.emoji)}">${r.emoji} ${r.count}</button>`
|
|
).join('');
|
|
const summary = totalResponses > 0 ? `<span class="reaction-summary">${totalResponses} response${totalResponses !== 1 ? 's' : ''}</span>` : '';
|
|
reactionsHtml = pills + summary;
|
|
}
|
|
|
|
const commentCount = post.commentCount || 0;
|
|
const shareBtn = post.visibility === 'public'
|
|
? `<button class="share-btn" data-post-id="${post.id}" title="Copy share link">Share</button>`
|
|
: '';
|
|
const engagementBar = `<div class="engagement-bar">
|
|
<div class="reaction-pills">${reactionsHtml}<button class="react-btn" data-post-id="${post.id}" title="React">\u263A</button></div>
|
|
<div class="engagement-right"><button class="comment-toggle-btn" data-post-id="${post.id}">Comment${commentCount > 0 ? ` (${commentCount})` : ''}</button>${shareBtn}</div>
|
|
</div>`;
|
|
|
|
const recipientsData = (post.recipients || []).join(',');
|
|
return `<div class="post" style="animation-delay: ${delay}s" data-post-id="${post.id}" data-author="${post.author}" data-recipients="${recipientsData}">
|
|
<div class="post-meta">
|
|
<span class="post-author">${icon}${authorLink}${visBadge}</span>
|
|
<span class="post-time" title="${new Date(post.timestampMs).toLocaleString()}">${timeStr}</span>
|
|
${deleteBtn}
|
|
</div>
|
|
<div class="post-content">${displayContent}</div>
|
|
${attachmentsHtml}
|
|
${engagementBar}
|
|
<div class="comment-thread hidden" id="comments-${post.id}"></div>
|
|
<div class="post-id">${post.id.substring(0, 16)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Render a message post (similar to renderPost but with follow action for requests)
|
|
function renderMessage(post, index, showFollowBtn) {
|
|
const html = renderPost(post, index);
|
|
if (!showFollowBtn) return html;
|
|
// Insert a follow button after the post
|
|
return html + `<div class="msg-request-action">
|
|
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${post.author}">Follow to accept</button>
|
|
</div>`;
|
|
}
|
|
|
|
function renderEmptyState(message, hint) {
|
|
return `<div class="empty-state">
|
|
<div class="empty-state-icon"></div>
|
|
<p class="empty-state-msg">${escapeHtml(message)}</p>
|
|
${hint ? `<p class="empty-state-hint">${escapeHtml(hint)}</p>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function renderLoading() {
|
|
return '<div class="loading-state"><div class="loading-dots"><span></span><span></span><span></span></div></div>';
|
|
}
|
|
|
|
const TAB_BASE_LABELS = { feed: 'Feed', myposts: 'My Posts', people: 'People', messages: 'Messages', settings: 'Settings' };
|
|
function updateTabBadge(tabName, count) {
|
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
|
if (!tab) return;
|
|
// Update the label span (preserve icon span)
|
|
const label = tab.querySelector('.tab-label');
|
|
const base = TAB_BASE_LABELS[tabName] || tabName;
|
|
if (label) {
|
|
label.textContent = base;
|
|
}
|
|
// Update or create badge span
|
|
let badge = tab.querySelector('.tab-badge');
|
|
if (count > 0) {
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'tab-badge';
|
|
tab.appendChild(badge);
|
|
}
|
|
badge.textContent = count;
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
|
|
let _lastFeedViewMs = 0;
|
|
let _lastMyPostsViewMs = 0;
|
|
|
|
let _tickerTimeout = null;
|
|
let _lastTickerMs = 0;
|
|
function showTicker(msg) {
|
|
const now = Date.now();
|
|
if (now - _lastTickerMs < 1000) return; // max 1 update/sec
|
|
_lastTickerMs = now;
|
|
const el = $('#status-ticker');
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.classList.remove('faded');
|
|
if (_tickerTimeout) clearTimeout(_tickerTimeout);
|
|
_tickerTimeout = setTimeout(() => { el.classList.add('faded'); }, 3000);
|
|
}
|
|
|
|
async function updateNetworkIndicator() {
|
|
try {
|
|
const info = await invoke('get_network_summary');
|
|
const dot = $('#net-dot');
|
|
const labels = $('#net-labels');
|
|
if (!dot) return;
|
|
const total = info.totalConnections || 0;
|
|
dot.className = total === 0 ? 'net-black'
|
|
: total === 1 ? 'net-red'
|
|
: total <= 10 ? 'net-yellow'
|
|
: 'net-green';
|
|
dot.id = 'net-dot';
|
|
// Build labels
|
|
let labelHtml = '';
|
|
if (info.hasPublicV6) labelHtml += '<span class="net-label">Public</span>';
|
|
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
|
|
if (labels) labels.innerHTML = labelHtml;
|
|
// Only show connection ticker on state change
|
|
if (typeof updateNetworkIndicator._lastTotal === 'undefined') updateNetworkIndicator._lastTotal = -1;
|
|
if (total !== updateNetworkIndicator._lastTotal) {
|
|
if (total === 0) showTicker('Connecting...');
|
|
else if (updateNetworkIndicator._lastTotal === 0) showTicker('Connected');
|
|
else if (total > updateNetworkIndicator._lastTotal) showTicker(`${total} peers connected`);
|
|
updateNetworkIndicator._lastTotal = total;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
// --- Compose auto-grow ---
|
|
function autoGrow(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.max(el.scrollHeight, 60) + 'px';
|
|
}
|
|
|
|
function updateCharCount() {
|
|
const len = postContent.value.length;
|
|
charCount.textContent = `${len}/${POST_MAX_CHARS}`;
|
|
charCount.classList.toggle('char-warn', len > POST_MAX_CHARS * 0.9);
|
|
charCount.classList.toggle('char-over', len >= POST_MAX_CHARS);
|
|
}
|
|
|
|
// --- Data loaders ---
|
|
async function loadNodeInfo() {
|
|
try {
|
|
const info = await invoke('get_node_info');
|
|
myNodeId = info.nodeId;
|
|
const label = info.displayName || info.nodeId.substring(0, 16) + '...';
|
|
const icon = generateIdenticon(info.nodeId, 18);
|
|
nodeIdEl.innerHTML = icon + ' <span>' + escapeHtml(label) + '</span>';
|
|
nodeIdEl.title = info.nodeId;
|
|
connectString = info.connectString;
|
|
// Show connect string and QR code
|
|
const csDisplay = $('#connect-string-display');
|
|
const qrContainer = $('#qr-code');
|
|
if (csDisplay && connectString) {
|
|
csDisplay.textContent = connectString;
|
|
}
|
|
if (qrContainer && connectString) {
|
|
try {
|
|
qrContainer.innerHTML = generateQRCodeSVG(connectString, 200);
|
|
} catch (qrErr) {
|
|
qrContainer.innerHTML = '<span class="empty-hint">QR code unavailable</span>';
|
|
}
|
|
}
|
|
// Pre-fill profile editor with current values
|
|
if (info.displayName && !profileNameInput.dataset.touched) {
|
|
profileNameInput.value = info.displayName;
|
|
}
|
|
return info;
|
|
} catch (e) {
|
|
nodeIdEl.textContent = 'error';
|
|
console.error('loadNodeInfo:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
await invoke('get_stats');
|
|
// Stats bar removed — tab badges show contextual counts
|
|
} catch (e) {
|
|
console.error('loadStats:', e);
|
|
}
|
|
}
|
|
|
|
// --- 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 = `<p class="status-err">Error: ${e}</p>`;
|
|
} 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 = `<p class="status-err">Error: ${e}</p>`;
|
|
} 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 = `<div class="section-card">${renderEmptyState(
|
|
'No conversations yet',
|
|
'Send a DM above to start a conversation.'
|
|
)}</div>`;
|
|
} else {
|
|
conversationsList.innerHTML = sortedThreads.map(([partnerId, thread]) => {
|
|
const lastMsg = thread.posts[thread.posts.length - 1];
|
|
const icon = generateIdenticon(partnerId, 22);
|
|
const name = escapeHtml(peerLabel(partnerId, thread.partnerName));
|
|
const time = relativeTime(lastMsg.timestampMs);
|
|
const preview = lastMsg.decryptedContent || lastMsg.content || '';
|
|
const previewText = escapeHtml(preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
|
|
const prefix = lastMsg.isMe ? '<span class="conv-you">You: </span>' : '';
|
|
|
|
const messagesHtml = thread.posts.map(p => {
|
|
const content = p.decryptedContent || p.content || '';
|
|
const msgTime = relativeTime(p.timestampMs);
|
|
const side = p.isMe ? 'chat-mine' : 'chat-theirs';
|
|
const isEncrypted = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
|
|
const receiptAttr = isEncrypted ? ` data-post-id="${p.id}"` : '';
|
|
const reactBtn = isEncrypted ? `<button class="slot-react-btn" data-post-id="${p.id}" title="React">+</button>` : '';
|
|
return `<div class="chat-bubble ${side}"${receiptAttr}>
|
|
<div class="chat-text">${escapeHtml(content)}</div>
|
|
<div class="chat-meta"><span class="chat-time">${msgTime}</span>${p.isMe && isEncrypted ? '<span class="receipt-indicator" data-post-id="' + p.id + '"></span>' : ''}${reactBtn}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `<div class="conversation-item section-card" data-partner="${partnerId}">
|
|
<div class="conv-header">
|
|
<div class="conv-header-left">${icon} <span class="conv-name">${name}</span></div>
|
|
<span class="conv-time">${time}</span>
|
|
</div>
|
|
<div class="conv-preview">${prefix}${previewText}</div>
|
|
<div class="conversation-messages hidden">
|
|
<div class="chat-window">${messagesHtml}</div>
|
|
<div class="conv-reply">
|
|
<textarea class="conv-reply-input" placeholder="Reply..." rows="2"></textarea>
|
|
<button class="btn btn-primary btn-sm conv-reply-btn" data-partner="${partnerId}">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Open conversation in popover on click
|
|
conversationsList.querySelectorAll('.conversation-item').forEach(item => {
|
|
item.querySelector('.conv-header').addEventListener('click', () => {
|
|
const partnerId = item.dataset.partner;
|
|
const msgsHtml = item.querySelector('.chat-window').innerHTML;
|
|
const partnerName = item.querySelector('.conv-name').textContent;
|
|
|
|
// Find the thread data from sortedThreads
|
|
const threadEntry = sortedThreads.find(([pid]) => pid === partnerId);
|
|
const threadPosts = threadEntry ? threadEntry[1].posts : [];
|
|
|
|
// Collect post IDs for receipt/seen tracking
|
|
const threadPostIds = threadPosts.filter(p => {
|
|
const enc = p.visibility === 'encrypted-for-me' || p.intentKind === 'direct' || (p.recipients && p.recipients.length > 0);
|
|
return enc;
|
|
}).map(p => p.id);
|
|
|
|
openPopover(partnerName, `
|
|
<div class="chat-window" style="max-height:55vh;overflow-y:auto;display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem 0">${msgsHtml}</div>
|
|
<div id="popover-slot-comments" style="max-height:15vh;overflow-y:auto;padding:0.3rem 0;border-top:1px solid #2a2a40;display:none"></div>
|
|
<div class="conv-reply" style="display:flex;gap:0.4rem;align-items:flex-end;margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #2a2a40">
|
|
<textarea class="conv-reply-input" id="popover-reply-input" placeholder="Reply..." rows="2" style="flex:1;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:none;font-family:inherit;font-size:0.85rem;min-height:36px;line-height:1.4"></textarea>
|
|
<button class="btn btn-primary btn-sm" id="popover-reply-btn">Send</button>
|
|
</div>
|
|
`, {
|
|
onOpen() {
|
|
// Scroll chat to bottom
|
|
const chatWindow = $('#popover-body .chat-window');
|
|
if (chatWindow) chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
// Focus reply
|
|
const input = $('#popover-reply-input');
|
|
if (input) setTimeout(() => input.focus(), 100);
|
|
|
|
// Mark conversation as read (DB-backed) and clear notifications
|
|
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
|
clearNotifications(`msg-`);
|
|
|
|
// Mark incoming encrypted messages as "seen"
|
|
for (const p of threadPosts) {
|
|
if (!p.isMe && threadPostIds.includes(p.id)) {
|
|
invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Load receipt indicators for sent messages
|
|
loadReceiptIndicators(chatWindow);
|
|
|
|
// Wire react buttons
|
|
chatWindow.querySelectorAll('.slot-react-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const pid = btn.dataset.postId;
|
|
const emoji = prompt('Emoji reaction:');
|
|
if (emoji && emoji.trim()) {
|
|
try {
|
|
await invoke('write_message_receipt', { postId: pid, receiptState: 'reacted', emoji: emoji.trim() });
|
|
toast('Reacted!');
|
|
} catch (err) {
|
|
toast('Error: ' + err);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Wire send
|
|
const sendReply = async () => {
|
|
const content = input.value.trim();
|
|
if (!content) return;
|
|
$('#popover-reply-btn').disabled = true;
|
|
try {
|
|
await invoke('create_post', { content, visibility: 'direct', recipientHex: partnerId });
|
|
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
|
input.value = '';
|
|
toast('Reply sent!');
|
|
closePopover();
|
|
loadMessages(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
const btn = $('#popover-reply-btn');
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
};
|
|
$('#popover-reply-btn').addEventListener('click', sendReply);
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) sendReply(); });
|
|
},
|
|
onClose() {
|
|
// Mark conversation as read when closing the popover
|
|
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
|
clearNotifications(`msg-`);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Conversations open in popovers now — no inline restore needed
|
|
}
|
|
|
|
// Message requests from non-followed
|
|
if (requests.length === 0) {
|
|
messageRequestsList.innerHTML = `<p class="empty-hint">No pending requests</p>`;
|
|
} else {
|
|
messageRequestsList.innerHTML = requests.map((p, i) => renderMessage(p, i, true)).join('');
|
|
attachFollowHandlers(messageRequestsList);
|
|
}
|
|
|
|
// Count unread conversations (messages newer than last_read_ms)
|
|
let unreadCount = requests.length;
|
|
for (const [partnerId, thread] of sortedThreads) {
|
|
const lastReadMs = await invoke('get_last_read_message', { partnerIdHex: partnerId }).catch(() => 0);
|
|
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
|
|
if (hasUnread) unreadCount++;
|
|
}
|
|
updateTabBadge('messages', unreadCount);
|
|
} catch (e) {
|
|
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
|
|
}
|
|
}
|
|
|
|
// Load receipt indicators (checkmarks) for sent messages in a chat window
|
|
async function loadReceiptIndicators(chatWindow) {
|
|
if (!chatWindow) return;
|
|
const indicators = chatWindow.querySelectorAll('.receipt-indicator[data-post-id]');
|
|
for (const el of indicators) {
|
|
const postId = el.dataset.postId;
|
|
try {
|
|
const receipts = await invoke('get_message_receipts', { postId });
|
|
if (!receipts || receipts.length === 0) continue;
|
|
// Find the best state among non-self receipts
|
|
let bestState = 'empty';
|
|
let reactionEmoji = null;
|
|
for (const r of receipts) {
|
|
if (r.nodeId && r.nodeId !== myNodeId) {
|
|
if (r.state === 'reacted') { bestState = 'reacted'; reactionEmoji = r.emoji; break; }
|
|
if (r.state === 'seen' && bestState !== 'reacted') bestState = 'seen';
|
|
if (r.state === 'delivered' && bestState === 'empty') bestState = 'delivered';
|
|
}
|
|
}
|
|
if (bestState === 'delivered') {
|
|
el.innerHTML = '<span class="receipt-check" title="Delivered">✓</span>';
|
|
} else if (bestState === 'seen') {
|
|
el.innerHTML = '<span class="receipt-check seen" title="Seen">✓✓</span>';
|
|
} else if (bestState === 'reacted') {
|
|
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '✓✓')}</span>`;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
async function loadDmRecipientOptions() {
|
|
try {
|
|
const [follows, peers] = await Promise.all([
|
|
invoke('list_follows'),
|
|
invoke('list_peers'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
const otherPeers = peers.filter(p => !followSet.has(p.nodeId) && p.nodeId !== myNodeId);
|
|
|
|
let html = '<option value="">(select recipient)</option>';
|
|
if (follows.length > 0) {
|
|
html += '<optgroup label="Following">';
|
|
html += follows.map(f => {
|
|
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${f.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
html += '</optgroup>';
|
|
}
|
|
if (otherPeers.length > 0) {
|
|
html += '<optgroup label="Other Peers">';
|
|
html += otherPeers.map(p => {
|
|
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${p.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
html += '</optgroup>';
|
|
}
|
|
dmRecipientSelect.innerHTML = html;
|
|
} catch (e) {
|
|
dmRecipientSelect.innerHTML = '<option value="">Error loading</option>';
|
|
}
|
|
}
|
|
|
|
async function loadPeers() {
|
|
if (!peersList) return;
|
|
try {
|
|
const [peers, follows] = await Promise.all([
|
|
invoke('list_peers'),
|
|
invoke('list_follows'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
|
|
if (peers.length === 0) {
|
|
peersList.innerHTML = renderEmptyState('No known peers', 'Connect to a peer using their connect string, or wait for mDNS discovery.');
|
|
} else {
|
|
// Sort by reach level, then by last_seen within each level
|
|
const reachOrder = { mesh: 0, n1: 1, n2: 2, n3: 3, known: 4 };
|
|
const sorted = [...peers].sort((a, b) => {
|
|
const ra = reachOrder[a.reach] ?? 4;
|
|
const rb = reachOrder[b.reach] ?? 4;
|
|
if (ra !== rb) return ra - rb;
|
|
return (b.lastSeen || 0) - (a.lastSeen || 0);
|
|
});
|
|
|
|
peersList.innerHTML = sorted.map(p => {
|
|
const label = escapeHtml(peerLabel(p.nodeId, p.displayName));
|
|
const icon = generateIdenticon(p.nodeId, 18);
|
|
const anchor = p.isAnchor ? '<span class="anchor-badge">anchor</span>' : '';
|
|
const intro = p.introducedBy ? `<span class="intro-tag">via ${escapeHtml(p.introducedBy)}</span>` : '';
|
|
const addr = p.addresses.length > 0 ? `<span class="peer-addr">${escapeHtml(p.addresses[0])}</span>` : '';
|
|
const seen = p.lastSeen ? `<span class="peer-seen">${relativeTime(p.lastSeen)}</span>` : '';
|
|
|
|
let reachBadge = '';
|
|
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
|
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
|
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
|
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
|
|
|
let actions = '';
|
|
if (p.nodeId === myNodeId) {
|
|
actions = '<span class="self-tag">(you)</span>';
|
|
} else {
|
|
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>`;
|
|
const followBtn = followSet.has(p.nodeId)
|
|
? `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${p.nodeId}">Unfollow</button>`
|
|
: `<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>`;
|
|
actions = `${msgBtn} ${followBtn}`;
|
|
}
|
|
|
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${p.nodeId}">${label}</a> ${reachBadge} ${anchor}</div>
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-meta">${intro} ${addr} ${seen}</div>
|
|
<div class="peer-card-actions">${actions}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
attachFollowHandlers(peersList);
|
|
// Lazy-load bios
|
|
loadPeerBios(peersList);
|
|
}
|
|
} catch (e) {
|
|
peersList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadPeerBios(container) {
|
|
const cards = container.querySelectorAll('[data-node-id]');
|
|
for (const card of cards) {
|
|
const nodeId = card.dataset.nodeId;
|
|
if (!nodeId || nodeId === myNodeId) continue;
|
|
try {
|
|
const info = await invoke('resolve_display', { nodeIdHex: nodeId });
|
|
const bioEl = card.querySelector('.peer-card-bio');
|
|
if (bioEl && info.bio) {
|
|
bioEl.textContent = info.bio;
|
|
bioEl.classList.add('peer-bio');
|
|
}
|
|
} catch (e) { /* ignore — peer may not have profile */ }
|
|
}
|
|
}
|
|
|
|
async function loadFollows() {
|
|
try {
|
|
// 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 = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
|
|
} else {
|
|
const now = Date.now();
|
|
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
const renderFollowCard = (f) => {
|
|
const icon = generateIdenticon(f.nodeId, 18);
|
|
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
|
const isSelf = f.nodeId === myNodeId;
|
|
|
|
let lastSeenHtml = '';
|
|
let actions = '';
|
|
if (isSelf) {
|
|
actions = '<span class="self-tag">(you)</span>';
|
|
} else {
|
|
if (!f.isOnline && f.lastActivityMs > 0) {
|
|
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
|
|
}
|
|
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
|
|
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
|
|
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
|
|
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
|
}
|
|
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a></div>
|
|
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-actions">${actions}</div>
|
|
</div>`;
|
|
};
|
|
|
|
// If isOnline field isn't available (old build), show all as online
|
|
const hasOnlineField = others.some(f => f.isOnline !== undefined);
|
|
const online = hasOnlineField
|
|
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
|
|
: others;
|
|
const offline = hasOnlineField
|
|
? others.filter(f => !online.includes(f))
|
|
: [];
|
|
|
|
updateTabBadge('people', online.length);
|
|
|
|
let html = '';
|
|
if (online.length > 0) {
|
|
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
|
html += online.map(renderFollowCard).join('');
|
|
}
|
|
if (offline.length > 0) {
|
|
html += `<div class="follows-section-header follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
|
|
}
|
|
followsList.innerHTML = html;
|
|
|
|
// Open offline follows in lightbox
|
|
if (offline.length > 0) {
|
|
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
|
|
hdr.addEventListener('click', () => {
|
|
const existing = document.querySelector('.offline-lightbox');
|
|
if (existing) existing.remove();
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'offline-lightbox';
|
|
overlay.innerHTML = `
|
|
<div class="offline-lightbox-content">
|
|
<div class="offline-lightbox-header">
|
|
<h3>Following: Offline (${offline.length})</h3>
|
|
<button class="offline-lightbox-close">x</button>
|
|
</div>
|
|
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
|
|
// Wire up buttons inside the lightbox
|
|
attachFollowHandlers(overlay);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Attach unfollow handlers
|
|
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Unfollowed');
|
|
loadFollows();
|
|
|
|
loadStats();
|
|
loadFeed(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Attach per-peer sync handlers
|
|
followsList.querySelectorAll('.sync-peer-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Syncing...';
|
|
try {
|
|
await invoke('sync_from_peer', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Sync complete!');
|
|
loadFeed(true);
|
|
loadMyPosts(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sync';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Lazy-load bios
|
|
loadPeerBios(followsList);
|
|
}
|
|
} catch (e) {
|
|
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
// loadSuggested removed — Suggested Peers section removed from UI
|
|
|
|
async function loadDiscoverPeople() {
|
|
const container = $('#discover-list');
|
|
try {
|
|
const [peers, follows] = await Promise.all([
|
|
invoke('list_peers'),
|
|
invoke('list_follows'),
|
|
]);
|
|
const followSet = new Set(follows.map(f => f.nodeId));
|
|
|
|
// Filter: has display name, not already followed, not self
|
|
const discoverable = peers.filter(p =>
|
|
p.displayName && !followSet.has(p.nodeId) && p.nodeId !== myNodeId
|
|
);
|
|
|
|
if (discoverable.length === 0) {
|
|
container.innerHTML = renderEmptyState(
|
|
'No new people found',
|
|
'Connect to more peers to discover people on the network.'
|
|
);
|
|
} else {
|
|
container.innerHTML = discoverable.map(p => {
|
|
const icon = generateIdenticon(p.nodeId, 18);
|
|
const label = escapeHtml(p.displayName);
|
|
let reachBadge = '';
|
|
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
|
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
|
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
|
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
|
|
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
<div class="peer-card-row">${icon} ${label} ${reachBadge}</div>
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-actions">
|
|
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
|
<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
attachFollowHandlers(container);
|
|
loadPeerBios(container);
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// Shared handler for follow/unfollow buttons in a container
|
|
function attachFollowHandlers(container) {
|
|
container.querySelectorAll('.follow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('follow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Followed! Syncing posts...');
|
|
loadFollows();
|
|
loadStats();
|
|
loadFeed(true);
|
|
if (currentTab === 'messages') loadMessages(true);
|
|
// Auto-sync triggers in backend; refresh feed again after a delay
|
|
setTimeout(() => loadFeed(true), 3000);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
container.querySelectorAll('.unfollow-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Unfollowed');
|
|
loadFollows();
|
|
loadStats();
|
|
loadFeed(true);
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadRedundancy() {
|
|
try {
|
|
const r = await invoke('get_redundancy_info');
|
|
const zeroClass = r.zeroReplicas > 0 ? 'warn' : 'ok';
|
|
const oneClass = r.oneReplica > 0 ? '' : 'ok';
|
|
redundancyPanel.innerHTML = `<div class="redundancy-grid">
|
|
<div class="redundancy-item ${zeroClass}">
|
|
<div class="redundancy-value">${r.zeroReplicas}</div>
|
|
<div class="redundancy-label">Unreplicated</div>
|
|
</div>
|
|
<div class="redundancy-item ${oneClass}">
|
|
<div class="redundancy-value">${r.oneReplica}</div>
|
|
<div class="redundancy-label">1 replica</div>
|
|
</div>
|
|
<div class="redundancy-item ok">
|
|
<div class="redundancy-value">${r.twoPlusReplicas}</div>
|
|
<div class="redundancy-label">2+ replicas</div>
|
|
</div>
|
|
<div class="redundancy-item">
|
|
<div class="redundancy-value">${r.total}</div>
|
|
<div class="redundancy-label">Total posts</div>
|
|
</div>
|
|
</div>`;
|
|
} catch (e) {
|
|
redundancyPanel.innerHTML = `<p class="empty-hint">Could not load redundancy info</p>`;
|
|
}
|
|
}
|
|
|
|
// 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 = `<div class="diag-grid">
|
|
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
|
|
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
|
|
</div>`;
|
|
} catch (e) {
|
|
networkSummaryEl.innerHTML = `<p class="empty-hint">Could not load network summary</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadConnections() {
|
|
if (!connectionsList) return;
|
|
try {
|
|
const conns = await invoke('list_connections');
|
|
if (conns.length === 0) {
|
|
connectionsList.innerHTML = '<p class="empty-hint">No active mesh connections</p>';
|
|
} else {
|
|
connectionsList.innerHTML = conns.map(c => {
|
|
const label = escapeHtml(peerLabel(c.nodeId, c.displayName));
|
|
const icon = generateIdenticon(c.nodeId, 18);
|
|
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
|
|
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
|
|
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
|
|
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
|
|
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
|
|
return `<div class="peer-card">
|
|
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
|
|
<div class="peer-card-meta"><span>${duration}</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
connectionsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadAllDiagnostics() {
|
|
const tasks = [loadNetworkSummary(), loadActivityLog()];
|
|
// Only load connections if section is visible
|
|
const connSection = $('#connections-section');
|
|
if (connSection && !connSection.classList.contains('hidden')) {
|
|
tasks.push(loadConnections());
|
|
}
|
|
await Promise.all(tasks);
|
|
lastDiagUpdate = Date.now();
|
|
const ts = $('#diag-update-time');
|
|
if (ts) ts.textContent = 'Updated ' + relativeTime(lastDiagUpdate);
|
|
}
|
|
|
|
let activityInterval = null;
|
|
|
|
async function loadActivityLog() {
|
|
try {
|
|
const data = await invoke('get_activity_log');
|
|
// Timers removed — rebalance/anchor register countdowns not useful for users
|
|
// Render events (newest first)
|
|
const logEl = $('#activity-log');
|
|
if (logEl) {
|
|
if (!data.events || data.events.length === 0) {
|
|
logEl.innerHTML = '<div class="activity-empty">No activity yet</div>';
|
|
} else {
|
|
const reversed = [...data.events].reverse();
|
|
logEl.innerHTML = reversed.map(e => {
|
|
const t = new Date(e.timestampMs);
|
|
const time = t.toTimeString().slice(0, 8);
|
|
const peer = e.peerId ? e.peerId.slice(0, 8) : '';
|
|
const fullId = e.peerId || '';
|
|
return `<div class="activity-entry level-${e.level}">
|
|
<span class="activity-time">${time}</span>
|
|
<span class="activity-badge badge-${e.category}">${e.category}</span>
|
|
<span class="activity-msg">${escapeHtml(e.message)}</span>
|
|
${peer ? `<span class="activity-peer" data-full-id="${fullId}">${peer}</span>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
// Attach click handlers for peer IDs
|
|
logEl.querySelectorAll('.activity-peer[data-full-id]').forEach(span => {
|
|
span.addEventListener('click', () => {
|
|
const expanded = span.parentElement.querySelector('.activity-peer-expanded');
|
|
if (expanded) { expanded.remove(); return; }
|
|
const div = document.createElement('div');
|
|
div.className = 'activity-peer-expanded';
|
|
div.textContent = span.dataset.fullId;
|
|
span.parentElement.appendChild(div);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const logEl = $('#activity-log');
|
|
if (logEl) logEl.innerHTML = `<div class="activity-empty">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderTimer(label, lastMs, intervalSecs, now) {
|
|
if (!lastMs || lastMs === 0) {
|
|
return `<div class="timer-card">
|
|
<div class="timer-label">${label}</div>
|
|
<div class="timer-value">--:--</div>
|
|
<div class="timer-bar"><div class="timer-bar-fill" style="width:0%"></div></div>
|
|
</div>`;
|
|
}
|
|
const intervalMs = intervalSecs * 1000;
|
|
const elapsed = now - lastMs;
|
|
const remaining = Math.max(0, intervalMs - elapsed);
|
|
const mins = Math.floor(remaining / 60000);
|
|
const secs = Math.floor((remaining % 60000) / 1000);
|
|
const pct = Math.min(100, (elapsed / intervalMs) * 100);
|
|
return `<div class="timer-card">
|
|
<div class="timer-label">${label}</div>
|
|
<div class="timer-value">${mins}:${secs.toString().padStart(2, '0')}</div>
|
|
<div class="timer-bar"><div class="timer-bar-fill" style="width:${pct.toFixed(0)}%"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function formatTimeAgo(timestampMs) {
|
|
const diff = Date.now() - timestampMs;
|
|
if (diff < 0 || timestampMs === 0) return 'unknown';
|
|
const secs = Math.floor(diff / 1000);
|
|
if (secs < 60) return 'just now';
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
// --- Anchor management ---
|
|
let currentAnchors = []; // hex node IDs
|
|
|
|
async function loadMyAnchors() {
|
|
try {
|
|
const [info, anchorPeers] = await Promise.all([
|
|
invoke('get_node_info'),
|
|
invoke('list_anchor_peers'),
|
|
]);
|
|
currentAnchors = info.anchors || [];
|
|
|
|
// Render current anchors
|
|
if (currentAnchors.length === 0) {
|
|
anchorsList.innerHTML = '<p class="empty-hint">No anchors set</p>';
|
|
} else {
|
|
// Get display names for anchor IDs
|
|
anchorsList.innerHTML = currentAnchors.map(aid => {
|
|
const peer = anchorPeers.find(p => p.nodeId === aid);
|
|
const name = peer ? peer.displayName : null;
|
|
const label = escapeHtml(peerLabel(aid, name));
|
|
const icon = generateIdenticon(aid, 18);
|
|
return `<div class="anchor-item">
|
|
<span class="peer-label">${icon} ${label}</span>
|
|
<button class="btn btn-danger btn-sm rm-anchor-btn" data-nid="${aid}">Remove</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Attach remove handlers
|
|
anchorsList.querySelectorAll('.rm-anchor-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => doRemoveAnchor(btn.dataset.nid));
|
|
});
|
|
}
|
|
|
|
// Populate add dropdown with anchor peers not already in our list
|
|
const available = anchorPeers.filter(p => !currentAnchors.includes(p.nodeId) && p.nodeId !== myNodeId);
|
|
if (available.length === 0) {
|
|
anchorAddSelect.innerHTML = '<option value="">(no anchor peers available)</option>';
|
|
} else {
|
|
anchorAddSelect.innerHTML = available.map(p => {
|
|
const label = p.displayName || p.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${p.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
anchorsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
async function doAddAnchor() {
|
|
const nid = anchorAddSelect.value;
|
|
if (!nid) { toast('Select an anchor peer'); return; }
|
|
anchorAddBtn.disabled = true;
|
|
try {
|
|
const newAnchors = [...currentAnchors, nid];
|
|
await invoke('set_anchors', { anchors: newAnchors });
|
|
toast('Anchor added');
|
|
loadMyAnchors();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
anchorAddBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function doRemoveAnchor(nid) {
|
|
try {
|
|
const newAnchors = currentAnchors.filter(a => a !== nid);
|
|
await invoke('set_anchors', { anchors: newAnchors });
|
|
toast('Anchor removed');
|
|
loadMyAnchors();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
}
|
|
}
|
|
|
|
if (anchorAddBtn) anchorAddBtn.addEventListener('click', doAddAnchor);
|
|
|
|
async function loadKnownAnchors() {
|
|
const container = $('#known-anchors-list');
|
|
try {
|
|
const anchors = await invoke('list_known_anchors');
|
|
if (anchors.length === 0) {
|
|
container.innerHTML = '<p class="empty-hint">No discovered anchors yet</p>';
|
|
} else {
|
|
container.innerHTML = anchors.map(a => {
|
|
const icon = generateIdenticon(a.nodeId, 18);
|
|
const label = escapeHtml(peerLabel(a.nodeId, a.displayName));
|
|
const addr = a.addresses.length > 0 ? `<span class="peer-addr">${escapeHtml(a.addresses[0])}</span>` : '';
|
|
return `<div class="anchor-item">
|
|
<span class="peer-label">${icon} ${label}</span>
|
|
${addr}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// --- Attachment handling ---
|
|
function renderAttachmentPreview() {
|
|
if (selectedFiles.length === 0) {
|
|
attachmentPreview.innerHTML = '';
|
|
return;
|
|
}
|
|
attachmentPreview.innerHTML = selectedFiles.map((f, i) => {
|
|
const isImage = f.mime.startsWith('image/');
|
|
const isVideo = f.mime.startsWith('video/');
|
|
if (isImage) {
|
|
const blob = new Blob([f.data], { type: f.mime });
|
|
const url = URL.createObjectURL(blob);
|
|
return `<div class="attach-thumb">
|
|
<img src="${url}" alt="${escapeHtml(f.name)}" />
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}
|
|
if (isVideo) {
|
|
const blob = new Blob([f.data], { type: f.mime });
|
|
const url = URL.createObjectURL(blob);
|
|
return `<div class="attach-thumb">
|
|
<video src="${url}#t=0.1" muted preload="auto" width="64" height="64" style="object-fit:cover;border-radius:4px;border:1px solid #444"></video>
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}
|
|
return `<div class="attach-thumb">
|
|
<span class="attach-file-name">${escapeHtml(f.name)}</span>
|
|
<button class="attach-remove" data-idx="${i}" title="Remove">x</button>
|
|
</div>`;
|
|
}).join('');
|
|
attachmentPreview.querySelectorAll('.attach-remove').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
selectedFiles.splice(parseInt(btn.dataset.idx), 1);
|
|
renderAttachmentPreview();
|
|
});
|
|
});
|
|
}
|
|
|
|
attachBtn.addEventListener('click', () => fileInput.click());
|
|
fileInput.addEventListener('change', () => {
|
|
const maxFiles = 4;
|
|
const maxSize = 10 * 1024 * 1024;
|
|
for (const file of fileInput.files) {
|
|
if (selectedFiles.length >= maxFiles) {
|
|
toast('Max 4 attachments');
|
|
break;
|
|
}
|
|
if (file.size > maxSize) {
|
|
toast(`${file.name} exceeds 10MB limit`);
|
|
continue;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
selectedFiles.push({ data: reader.result, mime: file.type || 'application/octet-stream', name: file.name });
|
|
renderAttachmentPreview();
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
fileInput.value = '';
|
|
});
|
|
|
|
async function loadBlobAsObjectUrl(cid, postId, mime) {
|
|
const b64 = await invoke('get_blob', { cidHex: cid, postIdHex: postId });
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return URL.createObjectURL(new Blob([bytes], { type: mime }));
|
|
}
|
|
|
|
async function loadPostMedia(container) {
|
|
const imgs = container.querySelectorAll('img[data-cid]');
|
|
for (const img of imgs) {
|
|
const cid = img.dataset.cid;
|
|
const postId = img.dataset.postId;
|
|
const mime = img.dataset.mime || 'image/jpeg';
|
|
try {
|
|
const filePath = await invoke('get_blob_path', { cidHex: cid, postIdHex: postId });
|
|
if (filePath && window.__TAURI__?.core?.convertFileSrc) {
|
|
const assetUrl = window.__TAURI__.core.convertFileSrc(filePath);
|
|
img.onerror = async () => {
|
|
// Asset protocol failed — fall back to base64 IPC
|
|
img.onerror = null;
|
|
try { img.src = await loadBlobAsObjectUrl(cid, postId, mime); }
|
|
catch (_) { img.alt = 'Image unavailable'; img.classList.add('post-image-missing'); }
|
|
};
|
|
img.src = assetUrl;
|
|
} else {
|
|
img.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
}
|
|
} catch (e) {
|
|
img.alt = 'Image unavailable';
|
|
img.classList.add('post-image-missing');
|
|
}
|
|
}
|
|
const vids = container.querySelectorAll('video[data-cid]');
|
|
for (const vid of vids) {
|
|
const cid = vid.dataset.cid;
|
|
const postId = vid.dataset.postId;
|
|
const mime = vid.dataset.mime || 'video/mp4';
|
|
try {
|
|
vid.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
vid.preload = 'auto';
|
|
} catch (e) {
|
|
vid.poster = '';
|
|
vid.insertAdjacentHTML('afterend', '<span class="empty-hint">Video unavailable</span>');
|
|
vid.remove();
|
|
}
|
|
}
|
|
const audios = container.querySelectorAll('audio[data-cid]');
|
|
for (const aud of audios) {
|
|
const cid = aud.dataset.cid;
|
|
const postId = aud.dataset.postId;
|
|
const mime = aud.dataset.mime || 'audio/mpeg';
|
|
try {
|
|
aud.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
|
} catch (e) {
|
|
aud.insertAdjacentHTML('afterend', '<span class="empty-hint">Audio unavailable</span>');
|
|
aud.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- File attachment download (PDFs, docs, etc.) ---
|
|
document.addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('.file-download');
|
|
if (!btn) return;
|
|
const cid = btn.dataset.cid;
|
|
const postId = btn.dataset.postId;
|
|
const ext = btn.dataset.ext || 'bin';
|
|
const filename = `${cid.slice(0, 8)}.${ext}`;
|
|
const postEl = btn.closest('.post');
|
|
const author = postEl?.querySelector('.post-author')?.textContent?.trim() || 'Unknown';
|
|
showDownloadPrompt(filename, author, cid, postId);
|
|
});
|
|
|
|
function showDownloadPrompt(filename, author, cid, postId) {
|
|
const existing = document.querySelector('.download-prompt-overlay');
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'download-prompt-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="download-prompt">
|
|
<h3>Download File</h3>
|
|
<p class="download-filename">${escapeHtml(filename)}</p>
|
|
<p class="download-warning">From: <strong>${escapeHtml(author)}</strong><br>
|
|
Only download files from people you trust.</p>
|
|
<div class="download-prompt-btns">
|
|
<button class="btn btn-secondary download-cancel">Cancel</button>
|
|
<button class="btn btn-primary download-save" data-open="false">Download</button>
|
|
<button class="btn btn-primary download-open" data-open="true">Download & Open</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
overlay.querySelector('.download-cancel').onclick = () => overlay.remove();
|
|
overlay.onclick = (ev) => { if (ev.target === overlay) overlay.remove(); };
|
|
|
|
const doDownload = async (shouldOpen) => {
|
|
overlay.remove();
|
|
try {
|
|
const path = await invoke(shouldOpen ? 'save_and_open_blob' : 'save_blob', {
|
|
cidHex: cid, postIdHex: postId, filename
|
|
});
|
|
toast('Saved to ' + path);
|
|
} catch (err) {
|
|
toast('Download failed: ' + err);
|
|
}
|
|
};
|
|
overlay.querySelector('.download-save').onclick = () => doDownload(false);
|
|
overlay.querySelector('.download-open').onclick = () => doDownload(true);
|
|
}
|
|
|
|
// --- Video speed control ---
|
|
document.addEventListener('change', (e) => {
|
|
if (!e.target.classList.contains('video-speed')) return;
|
|
const vid = e.target.closest('.video-wrap')?.querySelector('video');
|
|
if (vid) vid.playbackRate = parseFloat(e.target.value);
|
|
});
|
|
|
|
// --- Video download ---
|
|
document.addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('.video-download');
|
|
if (!btn) return;
|
|
const cid = btn.dataset.cid;
|
|
const vid = btn.closest('.video-wrap')?.querySelector('video');
|
|
const postId = vid?.dataset?.postId;
|
|
try {
|
|
const path = await invoke('save_and_open_blob', {
|
|
cidHex: cid, postIdHex: postId, filename: `video_${cid.slice(0, 8)}.mp4`
|
|
});
|
|
toast('Saved to ' + path);
|
|
} catch (_) {
|
|
toast('Download failed');
|
|
}
|
|
});
|
|
|
|
// --- Video expand/collapse (double-click to toggle fullscreen-like view) ---
|
|
// --- Image lightbox (click to open full-size popup) ---
|
|
document.addEventListener('click', (e) => {
|
|
// Close lightbox on click — 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 =>
|
|
`<button class="emoji-pick" data-post-id="${postId}" data-emoji="${em}">${em}</button>`
|
|
).join('');
|
|
e.target.parentElement.appendChild(picker);
|
|
return;
|
|
}
|
|
|
|
// Emoji pick → add reaction
|
|
if (e.target.classList.contains('emoji-pick')) {
|
|
const postId = e.target.dataset.postId;
|
|
const emoji = e.target.dataset.emoji;
|
|
closeEmojiPicker();
|
|
try {
|
|
await invoke('react_to_post', { postId, emoji, private: false });
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Reaction pill → toggle reaction
|
|
if (e.target.classList.contains('reaction-pill')) {
|
|
const postId = e.target.dataset.postId;
|
|
const emoji = e.target.dataset.emoji;
|
|
try {
|
|
if (e.target.classList.contains('reacted')) {
|
|
await invoke('remove_reaction', { postId, emoji });
|
|
} else {
|
|
await invoke('react_to_post', { postId, emoji, private: false });
|
|
}
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Share button → generate share link and copy to clipboard
|
|
if (e.target.classList.contains('share-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
try {
|
|
const link = await invoke('generate_share_link', { postIdHex: postId });
|
|
if (link) {
|
|
try {
|
|
await navigator.clipboard.writeText(link);
|
|
toast('Share link copied!');
|
|
} catch (clipErr) {
|
|
prompt('Copy your share link:', link);
|
|
}
|
|
} else {
|
|
toast('Only public posts can be shared');
|
|
}
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Comment toggle → expand/collapse thread
|
|
if (e.target.classList.contains('comment-toggle-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const postEl = e.target.closest('.post');
|
|
const threadEl = postEl ? postEl.querySelector('.comment-thread') : document.getElementById('comments-' + postId);
|
|
if (!threadEl) return;
|
|
if (threadEl.classList.contains('hidden')) {
|
|
threadEl.classList.remove('hidden');
|
|
await loadCommentThread(postId, threadEl);
|
|
} else {
|
|
threadEl.classList.add('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Comment send button
|
|
if (e.target.classList.contains('comment-send-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const input = e.target.parentElement.querySelector('.comment-input');
|
|
const content = input.value.trim();
|
|
if (!content) return;
|
|
e.target.disabled = true;
|
|
try {
|
|
await invoke('comment_on_post', { postId, content });
|
|
input.value = '';
|
|
const postEl = e.target.closest('.post');
|
|
const threadEl = postEl ? postEl.querySelector('.comment-thread') : document.getElementById('comments-' + postId);
|
|
if (threadEl) await loadCommentThread(postId, threadEl);
|
|
refreshPostEngagement(postId);
|
|
} catch (err) { toast('Error: ' + err); }
|
|
finally { e.target.disabled = false; }
|
|
return;
|
|
}
|
|
|
|
// Edit comment
|
|
if (e.target.classList.contains('comment-edit-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const ts = parseInt(e.target.dataset.ts);
|
|
const bubble = e.target.closest('.comment-bubble');
|
|
const textEl = bubble.querySelector('.comment-text');
|
|
const oldContent = textEl.textContent;
|
|
textEl.innerHTML = `<input class="comment-edit-input" value="${escapeHtml(oldContent)}" style="width:100%;background:#1a1a2e;color:#e0e0e0;border:1px solid #444;border-radius:3px;padding:0.2rem;font-size:0.8rem" />`;
|
|
const input = textEl.querySelector('.comment-edit-input');
|
|
input.focus();
|
|
input.addEventListener('keydown', async (ev) => {
|
|
if (ev.key === 'Enter') {
|
|
const newContent = input.value.trim();
|
|
if (newContent && newContent !== oldContent) {
|
|
try {
|
|
await invoke('edit_comment', { postId, timestampMs: ts, newContent });
|
|
const postEl = bubble.closest('.post');
|
|
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
|
|
if (threadEl) await loadCommentThread(postId, threadEl);
|
|
toast('Comment edited');
|
|
} catch (err) { toast('Error: ' + err); }
|
|
} else {
|
|
textEl.textContent = oldContent;
|
|
}
|
|
} else if (ev.key === 'Escape') {
|
|
textEl.textContent = oldContent;
|
|
}
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
if (textEl.querySelector('.comment-edit-input')) textEl.textContent = oldContent;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Delete comment
|
|
if (e.target.classList.contains('comment-delete-btn')) {
|
|
const postId = e.target.dataset.postId;
|
|
const ts = parseInt(e.target.dataset.ts);
|
|
if (!confirm('Delete this comment?')) return;
|
|
try {
|
|
await invoke('delete_comment', { postId, timestampMs: ts });
|
|
const postEl = e.target.closest('.post');
|
|
const threadEl = postEl ? postEl.querySelector('.comment-thread') : null;
|
|
if (threadEl) await loadCommentThread(postId, threadEl);
|
|
refreshPostEngagement(postId);
|
|
toast('Comment deleted');
|
|
} catch (err) { toast('Error: ' + err); }
|
|
return;
|
|
}
|
|
|
|
// Close emoji picker on outside click
|
|
closeEmojiPicker();
|
|
});
|
|
|
|
function closeEmojiPicker() {
|
|
document.querySelectorAll('.emoji-picker').forEach(p => p.remove());
|
|
}
|
|
|
|
async function loadCommentThread(postId, container) {
|
|
try {
|
|
const comments = await invoke('get_comment_thread', { postId });
|
|
let html = '';
|
|
for (const c of comments) {
|
|
const name = c.authorName || c.author.substring(0, 12);
|
|
const time = relativeTime(c.timestampMs);
|
|
const isMyComment = c.author === myNodeId;
|
|
const editDeleteBtns = isMyComment
|
|
? `<button class="comment-edit-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Edit">edit</button>
|
|
<button class="comment-delete-btn" data-post-id="${c.postId}" data-ts="${c.timestampMs}" title="Delete">del</button>`
|
|
: '';
|
|
html += `<div class="comment-bubble" data-post-id="${c.postId}" data-ts="${c.timestampMs}">
|
|
<span class="comment-author">${escapeHtml(name)}</span>
|
|
<span class="comment-text">${escapeHtml(c.content)}</span>
|
|
<span class="comment-time">${time} ${editDeleteBtns}</span>
|
|
</div>`;
|
|
}
|
|
html += `<div class="comment-compose">
|
|
<input class="comment-input" placeholder="Write a comment..." />
|
|
<button class="btn btn-primary btn-sm comment-send-btn" data-post-id="${postId}">Send</button>
|
|
</div>`;
|
|
container.innerHTML = html;
|
|
|
|
// Ctrl+Enter to send
|
|
const input = container.querySelector('.comment-input');
|
|
input.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter' && ev.ctrlKey) {
|
|
container.querySelector('.comment-send-btn').click();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
container.innerHTML = `<p class="empty-hint">Error loading comments</p>`;
|
|
}
|
|
}
|
|
|
|
async function refreshPostEngagement(postId) {
|
|
try {
|
|
const counts = await invoke('get_reaction_counts', { postId });
|
|
const commentCount = await invoke('get_comments', { postId }).then(c => c.length);
|
|
const postEl = document.querySelector(`.post[data-post-id="${postId}"]`);
|
|
if (!postEl) return;
|
|
|
|
// Update reaction pills
|
|
const pillsContainer = postEl.querySelector('.reaction-pills');
|
|
if (pillsContainer) {
|
|
const reactBtn = pillsContainer.querySelector('.react-btn');
|
|
let pillsHtml = counts.map(r =>
|
|
`<button class="reaction-pill${r.reactedByMe ? ' reacted' : ''}" data-post-id="${postId}" data-emoji="${r.emoji}">${r.emoji} ${r.count}</button>`
|
|
).join('');
|
|
pillsContainer.innerHTML = pillsHtml;
|
|
// Re-add react button
|
|
const btn = document.createElement('button');
|
|
btn.className = 'react-btn';
|
|
btn.dataset.postId = postId;
|
|
btn.title = 'React';
|
|
btn.textContent = '+';
|
|
pillsContainer.appendChild(btn);
|
|
}
|
|
|
|
// Update comment count
|
|
const commentBtn = postEl.querySelector('.comment-toggle-btn');
|
|
if (commentBtn) {
|
|
commentBtn.textContent = commentCount > 0 ? `Comment (${commentCount})` : 'Comment';
|
|
}
|
|
} catch (err) {
|
|
// Silently ignore refresh errors
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
async function doPost() {
|
|
const content = postContent.value.trim();
|
|
if (!content && selectedFiles.length === 0) return;
|
|
postBtn.disabled = true;
|
|
try {
|
|
const vis = visibilitySelect.value;
|
|
const params = { content: content || '' };
|
|
if (vis !== 'public') {
|
|
params.visibility = vis;
|
|
}
|
|
if (vis === 'circle') {
|
|
params.circleName = circleSelect.value;
|
|
if (!params.circleName) {
|
|
toast('Select a circle first');
|
|
postBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
// 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 = '<p class="empty-hint">Create a circle first in My Posts tab.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const c of circles) {
|
|
let profile = null;
|
|
try {
|
|
profile = await invoke('get_circle_profile', { circleName: c.name });
|
|
} catch (e) { /* no profile yet */ }
|
|
|
|
const dn = profile ? profile.displayName : '';
|
|
const bio = profile ? profile.bio : '';
|
|
|
|
html += `<div class="circle-profile-card section-card" style="margin-bottom:0.5rem;padding:0.75rem">
|
|
<strong>${escapeHtml(c.name)}</strong>
|
|
<div style="margin-top:0.25rem">
|
|
<label style="font-size:0.85rem">Display Name</label>
|
|
<input class="cp-name" data-circle="${escapeHtml(c.name)}" value="${escapeHtml(dn)}" placeholder="Circle display name" maxlength="50" />
|
|
<label style="font-size:0.85rem">Bio</label>
|
|
<textarea class="cp-bio" data-circle="${escapeHtml(c.name)}" placeholder="Circle bio" maxlength="200" rows="2">${escapeHtml(bio)}</textarea>
|
|
<div class="button-row" style="margin-top:0.25rem">
|
|
<button class="btn btn-primary btn-sm cp-save-btn" data-circle="${escapeHtml(c.name)}">Save</button>
|
|
${profile ? `<button class="btn btn-danger btn-sm cp-delete-btn" data-circle="${escapeHtml(c.name)}">Delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Attach save handlers
|
|
container.querySelectorAll('.cp-save-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const circle = btn.dataset.circle;
|
|
const nameInput = container.querySelector(`.cp-name[data-circle="${circle}"]`);
|
|
const bioInput = container.querySelector(`.cp-bio[data-circle="${circle}"]`);
|
|
const displayName = nameInput.value.trim();
|
|
const bio = bioInput.value.trim();
|
|
if (!displayName) {
|
|
toast('Display name is required');
|
|
return;
|
|
}
|
|
try {
|
|
await invoke('set_circle_profile', { circleName: circle, displayName, bio, avatarCid: null });
|
|
toast(`Circle profile for "${circle}" saved!`);
|
|
loadCircleProfiles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
|
|
// Attach delete handlers
|
|
container.querySelectorAll('.cp-delete-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const circle = btn.dataset.circle;
|
|
if (!confirm(`Delete circle profile for "${circle}"?`)) return;
|
|
try {
|
|
await invoke('delete_circle_profile', { circleName: circle });
|
|
toast(`Circle profile for "${circle}" deleted`);
|
|
loadCircleProfiles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
container.innerHTML = '<p class="empty-hint">Error loading circles.</p>';
|
|
console.error('loadCircleProfiles:', e);
|
|
}
|
|
}
|
|
|
|
// --- Visibility UI ---
|
|
function updateVisibilityUI() {
|
|
const vis = visibilitySelect.value;
|
|
circleSelect.classList.toggle('hidden', vis !== 'circle');
|
|
}
|
|
|
|
async function loadCircleOptions() {
|
|
try {
|
|
const circles = await invoke('list_circles');
|
|
circleSelect.innerHTML = circles.length === 0
|
|
? '<option value="">(no circles)</option>'
|
|
: circles.map(c => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)} (${c.members.length})</option>`).join('');
|
|
} catch (e) {
|
|
circleSelect.innerHTML = '<option value="">Error</option>';
|
|
}
|
|
}
|
|
|
|
visibilitySelect.addEventListener('change', () => {
|
|
updateVisibilityUI();
|
|
if (visibilitySelect.value === 'circle') loadCircleOptions();
|
|
});
|
|
|
|
// --- Circles management ---
|
|
async function loadCircles() {
|
|
try {
|
|
const circles = await invoke('list_circles');
|
|
if (circles.length === 0) {
|
|
circlesList.innerHTML = '<p class="empty-hint">No circles yet. Create one above.</p>';
|
|
return;
|
|
}
|
|
// Also load follows for display names and add-member dropdown
|
|
const follows = await invoke('list_follows');
|
|
const nameMap = {};
|
|
follows.forEach(f => { nameMap[f.nodeId] = f.displayName; });
|
|
|
|
circlesList.innerHTML = circles.map(c => {
|
|
const memberHtml = c.members.length === 0
|
|
? '<span class="empty-hint">No members</span>'
|
|
: c.members.map(mid => {
|
|
const displayName = nameMap[mid] || (mid.substring(0, 12) + '...');
|
|
return `<span class="circle-member">${escapeHtml(displayName)} <button class="btn btn-ghost btn-sm rm-member-btn" data-circle="${escapeHtml(c.name)}" data-nid="${mid}">Remove</button></span>`;
|
|
}).join(' ');
|
|
|
|
const addOptions = follows
|
|
.filter(f => !c.members.includes(f.nodeId))
|
|
.map(f => {
|
|
const label = f.displayName || f.nodeId.substring(0, 12) + '...';
|
|
return `<option value="${f.nodeId}">${escapeHtml(label)}</option>`;
|
|
}).join('');
|
|
|
|
return `<div class="circle-card">
|
|
<div class="circle-header">
|
|
<strong>${escapeHtml(c.name)}</strong> <span class="circle-count">(${c.members.length})</span>
|
|
<button class="btn btn-danger btn-sm del-circle-btn" data-name="${escapeHtml(c.name)}" style="margin-left:auto">Delete</button>
|
|
</div>
|
|
<div class="circle-members">${memberHtml}</div>
|
|
${addOptions ? `<div class="circle-add-row">
|
|
<select class="add-member-select">${addOptions}</select>
|
|
<button class="btn btn-primary btn-sm add-member-btn" data-circle="${escapeHtml(c.name)}">Add</button>
|
|
</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Attach handlers
|
|
circlesList.querySelectorAll('.del-circle-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm(`Delete circle "${btn.dataset.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
await invoke('delete_circle', { name: btn.dataset.name });
|
|
toast('Circle deleted');
|
|
loadCircles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
circlesList.querySelectorAll('.rm-member-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Remove this member from the circle?')) return;
|
|
try {
|
|
await invoke('remove_circle_member', { circleName: btn.dataset.circle, nodeIdHex: btn.dataset.nid });
|
|
toast('Member removed');
|
|
loadCircles();
|
|
// Offer to revoke past access
|
|
if (confirm('Revoke their access to past circle posts?')) {
|
|
try {
|
|
const count = await invoke('revoke_circle_access', {
|
|
circleName: btn.dataset.circle,
|
|
nodeIdHex: btn.dataset.nid,
|
|
});
|
|
toast(`Revoked access on ${count} posts`);
|
|
} catch (re) { toast('Revoke error: ' + re); }
|
|
}
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
circlesList.querySelectorAll('.add-member-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const select = btn.previousElementSibling;
|
|
if (!select.value) return;
|
|
try {
|
|
await invoke('add_circle_member', { circleName: btn.dataset.circle, nodeIdHex: select.value });
|
|
toast('Member added');
|
|
loadCircles();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
circlesList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
createCircleBtn.addEventListener('click', async () => {
|
|
const name = circleNameInput.value.trim();
|
|
if (!name) return;
|
|
createCircleBtn.disabled = true;
|
|
try {
|
|
await invoke('create_circle', { name });
|
|
circleNameInput.value = '';
|
|
toast('Circle created!');
|
|
loadCircles();
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
} finally {
|
|
createCircleBtn.disabled = false;
|
|
}
|
|
});
|
|
circleNameInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') createCircleBtn.click();
|
|
});
|
|
|
|
// --- Tab switching ---
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
if (tab.dataset.tab === currentTab) return;
|
|
// Close any open lightboxes/overlays/popovers
|
|
document.querySelectorAll('.image-lightbox, .download-prompt-overlay, .offline-lightbox').forEach(el => el.remove());
|
|
closePopover(); // hide the persistent popover overlay (don't remove it)
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
const oldView = document.querySelector('.view.active');
|
|
if (oldView) {
|
|
oldView.classList.remove('active');
|
|
oldView.classList.add('view-exit');
|
|
}
|
|
tab.classList.add('active');
|
|
const target = tab.dataset.tab;
|
|
const newView = document.querySelector(`#view-${target}`);
|
|
// Clear diagnostics auto-refresh when leaving Settings
|
|
if (diagnosticsInterval) { clearInterval(diagnosticsInterval); diagnosticsInterval = null; }
|
|
if (activityInterval) { clearInterval(activityInterval); activityInterval = null; }
|
|
requestAnimationFrame(() => {
|
|
if (oldView) oldView.classList.remove('view-exit');
|
|
newView.classList.add('active');
|
|
currentTab = target;
|
|
if (target === 'feed') {
|
|
_lastFeedViewMs = Date.now();
|
|
updateTabBadge('feed', 0);
|
|
if (!feedList.children.length) feedList.innerHTML = renderLoading();
|
|
loadFeed(true);
|
|
}
|
|
if (target === 'myposts') {
|
|
_lastMyPostsViewMs = Date.now();
|
|
updateTabBadge('myposts', 0);
|
|
loadMyPosts(true); loadCircles();
|
|
}
|
|
if (target === 'people') {
|
|
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
|
loadFollows(); loadAudience();
|
|
}
|
|
if (target === 'messages') {
|
|
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
|
loadMessages(true); loadDmRecipientOptions();
|
|
clearNotifications('msg-');
|
|
}
|
|
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- Collapsible section toggles ---
|
|
$('#profile-lightbox-btn').addEventListener('click', () => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
overlay.style.cursor = 'default';
|
|
// Pre-fill from the settings fields
|
|
const currentName = profileNameInput.value || '';
|
|
const currentBio = profileBioInput.value || '';
|
|
const currentVisible = $('#public-visible-check')?.checked ?? true;
|
|
overlay.innerHTML = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:450px;width:90%;max-height:80vh;overflow-y:auto">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem;text-align:center">Profiles</h3>
|
|
<h4 style="color:#ccc;font-size:0.85rem;margin:0 0 0.4rem">Default Profile</h4>
|
|
<label style="font-size:0.8rem;color:#888">Display Name</label>
|
|
<input id="lb-profile-name" type="text" value="${escapeHtml(currentName)}" placeholder="Your name" maxlength="50" style="width:100%;margin-bottom:0.5rem" />
|
|
<label style="font-size:0.8rem;color:#888">Bio</label>
|
|
<textarea id="lb-profile-bio" placeholder="Tell people about yourself..." maxlength="200" rows="3" style="width:100%;margin-bottom:0.5rem">${escapeHtml(currentBio)}</textarea>
|
|
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
|
|
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
|
|
Show my profile to non-circle peers
|
|
</label>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
|
|
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
|
|
</div>
|
|
<hr style="border-color:#333;margin:1rem 0" />
|
|
<h4 style="color:#ccc;font-size:0.85rem;margin:0 0 0.4rem">Circle Profiles</h4>
|
|
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Set a different name/bio for each circle you manage.</p>
|
|
<div id="lb-circle-profiles-list"></div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
|
|
<button class="btn btn-ghost btn-sm" id="lb-profile-close">Close</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('#lb-profile-save').addEventListener('click', async () => {
|
|
const name = overlay.querySelector('#lb-profile-name').value.trim();
|
|
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
|
|
if (!name) { toast('Display name is required'); return; }
|
|
try {
|
|
await invoke('set_profile', { name, bio });
|
|
const visible = overlay.querySelector('#lb-public-visible').checked;
|
|
await invoke('set_public_visible', { visible });
|
|
// Sync back to settings fields
|
|
profileNameInput.value = name;
|
|
profileBioInput.value = bio;
|
|
if ($('#public-visible-check')) $('#public-visible-check').checked = visible;
|
|
toast('Profile saved!');
|
|
loadNodeInfo();
|
|
overlay.remove();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
overlay.querySelector('#lb-profile-close').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
// Populate circle profiles list
|
|
const srcList = $('#circle-profiles-list');
|
|
const lbList = overlay.querySelector('#lb-circle-profiles-list');
|
|
if (srcList && lbList) lbList.innerHTML = srcList.innerHTML;
|
|
});
|
|
|
|
$('#redundancy-lightbox-btn').addEventListener('click', async () => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
overlay.style.cursor = 'default';
|
|
overlay.innerHTML = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem;text-align:center">Redundancy</h3>
|
|
<div id="lb-redundancy-panel"><p class="empty-hint">Loading...</p></div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
|
|
<button class="btn btn-ghost btn-sm" id="lb-redundancy-close">Close</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
// Load redundancy data
|
|
try {
|
|
const r = await invoke('get_redundancy_info');
|
|
const zeroClass = r.zeroReplicas > 0 ? 'warn' : 'ok';
|
|
const oneClass = r.oneReplica > 0 ? '' : 'ok';
|
|
overlay.querySelector('#lb-redundancy-panel').innerHTML = `<div class="redundancy-grid">
|
|
<div class="redundancy-item ${zeroClass}"><div class="redundancy-value">${r.zeroReplicas}</div><div class="redundancy-label">Unreplicated</div></div>
|
|
<div class="redundancy-item ${oneClass}"><div class="redundancy-value">${r.oneReplica}</div><div class="redundancy-label">1 replica</div></div>
|
|
<div class="redundancy-item ok"><div class="redundancy-value">${r.twoPlusReplicas}</div><div class="redundancy-label">2+ replicas</div></div>
|
|
<div class="redundancy-item"><div class="redundancy-value">${r.total}</div><div class="redundancy-label">Total posts</div></div>
|
|
</div>`;
|
|
} catch (_) {
|
|
overlay.querySelector('#lb-redundancy-panel').innerHTML = '<p class="empty-hint">Could not load redundancy info</p>';
|
|
}
|
|
overlay.querySelector('#lb-redundancy-close').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
});
|
|
|
|
$('#circles-toggle').addEventListener('click', () => {
|
|
const body = $('#circles-body');
|
|
body.classList.toggle('hidden');
|
|
$('#circles-toggle').textContent = body.classList.contains('hidden') ? 'Manage Circles' : 'Hide Circles';
|
|
});
|
|
|
|
$('#discover-toggle').addEventListener('click', () => {
|
|
const body = $('#discover-body');
|
|
body.classList.toggle('hidden');
|
|
$('#discover-toggle').textContent = body.classList.contains('hidden') ? 'Discover People' : 'Hide Discover';
|
|
if (!body.classList.contains('hidden')) loadDiscoverPeople();
|
|
});
|
|
|
|
$('#anchors-toggle').addEventListener('click', () => {
|
|
const body = $('#anchors-body');
|
|
body.classList.toggle('hidden');
|
|
$('#anchors-toggle').textContent = body.classList.contains('hidden') ? 'Stored Anchors' : 'Hide Anchors';
|
|
if (!body.classList.contains('hidden')) {
|
|
loadKnownAnchors();
|
|
loadMyAnchors();
|
|
}
|
|
});
|
|
|
|
function openDiagnostics() {
|
|
const diagHtml = `
|
|
<div id="network-summary"></div>
|
|
<div class="diag-actions" style="display:flex;gap:0.5rem;flex-wrap:wrap;justify-content:center">
|
|
<button id="diag-refresh-btn" class="btn btn-ghost btn-sm">Refresh</button>
|
|
<button id="diag-sync-btn" class="btn btn-ghost btn-sm">Sync All</button>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
|
|
<button id="rebalance-btn" class="btn btn-ghost btn-sm">Rebalance Now</button>
|
|
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
|
|
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
|
|
<button id="show-connections-btn" class="btn btn-ghost btn-sm">Show Connections</button>
|
|
<button id="show-anchors-btn" class="btn btn-ghost btn-sm">Stored Anchors</button>
|
|
</div>
|
|
<div id="ourinfo-section" class="hidden">
|
|
<h4 class="subsection-title">Our Info</h4>
|
|
<div id="ourinfo-content"></div>
|
|
</div>
|
|
<div id="connections-section" class="hidden">
|
|
<h4 class="subsection-title">Mesh & Session Connections</h4>
|
|
<div id="connections-list"></div>
|
|
</div>
|
|
<div id="anchors-section" class="hidden">
|
|
<h4 class="subsection-title">Stored Anchors</h4>
|
|
<p class="empty-hint" style="margin-bottom:0.5rem">Anchors discovered for reconnection beyond bootstrap.</p>
|
|
<div id="diag-known-anchors-list"></div>
|
|
</div>
|
|
<h4 class="subsection-title">Activity Log</h4>
|
|
<div id="activity-log" class="activity-log-container"></div>`;
|
|
openPopover('Network Diagnostics', diagHtml, {
|
|
onOpen() {
|
|
// Re-bind dynamic element refs
|
|
networkSummaryEl = $('#network-summary');
|
|
connectionsList = $('#connections-list');
|
|
peersList = null; // Known peers removed
|
|
// Wire 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 = `<div class="diag-grid" style="margin-bottom:0.5rem">
|
|
<div class="diag-item"><span class="diag-label">NAT Type</span><span class="diag-value" style="font-size:0.85rem">${info.natType}</span></div>
|
|
<div class="diag-item"><span class="diag-label">Role</span><span class="diag-value" style="font-size:0.85rem">${info.deviceRole}</span></div>
|
|
<div class="diag-item"><span class="diag-label">UPnP</span><span class="diag-value" style="font-size:0.85rem">${info.upnp ? 'Yes' : 'No'}</span></div>
|
|
</div>
|
|
<div style="text-align:center;margin-bottom:0.75rem">
|
|
<span style="color:#888;font-size:0.75rem">HTTP</span>
|
|
<span style="color:#ccc;font-size:0.8rem;margin-left:0.5rem">${httpVal}</span>
|
|
</div>`;
|
|
html += '<div style="font-size:0.8rem">';
|
|
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 += `<div style="padding:0.3rem 0;border-bottom:1px solid #1a1a2e">
|
|
<div style="color:#ccc;font-family:monospace;font-size:0.7rem;word-break:break-all">${a.addr}</div>
|
|
<div style="color:${color};font-size:0.7rem;margin-top:0.1rem">${a.family} · ${a.status}</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
html += `<p style="color:#555;font-size:0.7rem;text-align:center;margin-top:0.5rem;word-break:break-all">Node: ${info.nodeId.substring(0, 16)}…</p>`;
|
|
$('#ourinfo-content').innerHTML = html;
|
|
} catch (e) {
|
|
$('#ourinfo-content').innerHTML = `<p class="empty-hint">Failed to load: ${e}</p>`;
|
|
}
|
|
} 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 ? `<span class="peer-addr">${escapeHtml(a.addresses[0])}</span>` : '';
|
|
return `<div class="anchor-item"><span class="peer-label">${icon} ${label}</span>${addr}</div>`;
|
|
}).join('') : '<p class="empty-hint">No discovered anchors</p>';
|
|
} catch (_) {}
|
|
} else {
|
|
section.classList.add('hidden');
|
|
btn.textContent = 'Stored Anchors';
|
|
}
|
|
});
|
|
// Wire action buttons
|
|
$('#diag-refresh-btn').addEventListener('click', async () => {
|
|
const btn = $('#diag-refresh-btn');
|
|
btn.disabled = true; btn.textContent = 'Refreshing...';
|
|
try { await loadAllDiagnostics(); toast('Diagnostics refreshed'); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; btn.textContent = 'Refresh'; }
|
|
});
|
|
$('#diag-sync-btn').addEventListener('click', async () => {
|
|
const btn = $('#diag-sync-btn');
|
|
btn.disabled = true; btn.textContent = 'Syncing...';
|
|
try {
|
|
const result = await invoke('sync_all');
|
|
toast(result);
|
|
loadFeed(true);
|
|
if (currentTab === 'myposts') loadMyPosts(true);
|
|
if (currentTab === 'people') loadFollows();
|
|
if (currentTab === 'messages') loadMessages(true);
|
|
} catch (e) { toast('Sync error: ' + e); }
|
|
finally { btn.disabled = false; btn.textContent = 'Sync All'; }
|
|
});
|
|
$('#rebalance-btn').addEventListener('click', async () => {
|
|
const btn = $('#rebalance-btn');
|
|
btn.disabled = true; btn.textContent = 'Rebalancing...';
|
|
try { const r = await invoke('trigger_rebalance'); toast(r); loadAllDiagnostics(); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; btn.textContent = 'Rebalance Now'; }
|
|
});
|
|
$('#request-referrals-btn').addEventListener('click', async () => {
|
|
const btn = $('#request-referrals-btn');
|
|
btn.disabled = true; btn.textContent = 'Requesting...';
|
|
try {
|
|
const r = await invoke('request_referrals');
|
|
btn.textContent = r;
|
|
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
|
|
loadAllDiagnostics();
|
|
} catch (e) {
|
|
btn.textContent = 'Failed';
|
|
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
|
|
}
|
|
});
|
|
loadAllDiagnostics();
|
|
diagnosticsInterval = setInterval(loadAllDiagnostics, 10000);
|
|
activityInterval = setInterval(loadActivityLog, 3000);
|
|
}
|
|
});
|
|
}
|
|
$('#diagnostics-btn').addEventListener('click', openDiagnostics);
|
|
$('#net-indicator').addEventListener('click', openDiagnostics);
|
|
|
|
// --- Event handlers ---
|
|
postBtn.addEventListener('click', doPost);
|
|
postContent.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && e.ctrlKey) doPost();
|
|
});
|
|
postContent.addEventListener('input', () => {
|
|
autoGrow(postContent);
|
|
updateCharCount();
|
|
});
|
|
dmSendBtn.addEventListener('click', doSendDM);
|
|
dmContent.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && e.ctrlKey) doSendDM();
|
|
});
|
|
connectBtn.addEventListener('click', doConnect);
|
|
connectInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doConnect();
|
|
});
|
|
$('#connect-toggle').addEventListener('click', () => {
|
|
const body = $('#connect-body');
|
|
body.classList.toggle('hidden');
|
|
$('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually' : 'Cancel';
|
|
});
|
|
$('#share-details-btn').addEventListener('click', () => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
overlay.style.cursor = 'default';
|
|
let qrSvg = '';
|
|
try { qrSvg = generateQRCodeSVG(connectString, 200); } catch (_) {}
|
|
overlay.innerHTML = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Share My Details</h3>
|
|
<div style="margin-bottom:0.75rem">${qrSvg}</div>
|
|
<div class="connect-string-box" style="margin-bottom:0.75rem;font-size:0.7rem">${escapeHtml(connectString)}</div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-primary btn-sm" id="share-copy-btn">Copy</button>
|
|
<button class="btn btn-ghost btn-sm" id="share-close-btn">Close</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('#share-copy-btn').addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(connectString);
|
|
toast('Connect string copied!');
|
|
} catch (_) {
|
|
prompt('Copy your connect string:', connectString);
|
|
}
|
|
});
|
|
overlay.querySelector('#share-close-btn').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
});
|
|
if (syncBtn) syncBtn.addEventListener('click', doSyncAll);
|
|
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(connectString);
|
|
toast('Connect string copied!');
|
|
} catch (e) {
|
|
console.error('Clipboard write failed:', e);
|
|
prompt('Copy your connect string:', connectString);
|
|
}
|
|
});
|
|
if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
|
|
try {
|
|
const key = await invoke('export_identity');
|
|
try {
|
|
await navigator.clipboard.writeText(key);
|
|
toast('Identity key copied to clipboard. KEEP IT SECRET!');
|
|
} catch (clipErr) {
|
|
// Clipboard API may fail in some webview contexts — show the key instead
|
|
console.error('Clipboard write failed:', clipErr);
|
|
prompt('Copy your identity key (KEEP IT SECRET!):', key);
|
|
}
|
|
} catch (e) {
|
|
console.error('export_identity failed:', e);
|
|
toast('Error exporting key: ' + e);
|
|
}
|
|
});
|
|
saveProfileBtn.addEventListener('click', doSaveProfile);
|
|
// Mark profile inputs as touched so we don't overwrite user edits on auto-refresh
|
|
profileNameInput.addEventListener('input', () => { profileNameInput.dataset.touched = '1'; });
|
|
profileBioInput.addEventListener('input', () => { profileBioInput.dataset.touched = '1'; });
|
|
$('#circle-profiles-toggle').addEventListener('click', () => {
|
|
const body = $('#circle-profiles-body');
|
|
body.classList.toggle('hidden');
|
|
$('#circle-profiles-toggle').textContent = body.classList.contains('hidden') ? 'Circle Profiles' : 'Hide Circle Profiles';
|
|
if (!body.classList.contains('hidden')) loadCircleProfiles();
|
|
});
|
|
|
|
// --- Notifications popover ---
|
|
// Text size toggle
|
|
const TEXT_SIZE_SCALES = { xsmall: '75%', small: '100%', normal: '125%', large: '150%', xlarge: '200%' };
|
|
// Apply text size immediately from localStorage cache (no async wait)
|
|
const _cachedTextSize = localStorage.getItem('text_size') || 'normal';
|
|
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[_cachedTextSize] || '125%';
|
|
document.querySelectorAll('.text-size-opt').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.size === _cachedTextSize);
|
|
});
|
|
document.querySelectorAll('.text-size-opt').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const size = btn.dataset.size;
|
|
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[size] || '';
|
|
document.querySelectorAll('.text-size-opt').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
localStorage.setItem('text_size', size);
|
|
await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {});
|
|
toast('Text size updated');
|
|
});
|
|
});
|
|
|
|
// --- Identity management ---
|
|
async function loadIdentities() {
|
|
const list = $('#identities-list');
|
|
if (!list) return;
|
|
try {
|
|
const identities = await invoke('list_identities');
|
|
if (identities.length === 0) {
|
|
list.innerHTML = '<p class="empty-hint">No identities</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = identities.map(id => {
|
|
const active = id.isActive ? ' <span style="color:#7fdbca;font-size:0.7rem">(active)</span>' : '';
|
|
const switchBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm switch-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem">Switch</button>`;
|
|
const deleteBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm delete-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
|
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222">
|
|
<div>
|
|
<span style="font-weight:600">${escapeHtml(id.displayName)}</span>${active}
|
|
<div style="font-size:0.6rem;color:#666">${id.nodeId.substring(0, 16)}...</div>
|
|
</div>
|
|
<div style="display:flex;gap:0.3rem">${switchBtn}${deleteBtn}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Wire switch buttons
|
|
list.querySelectorAll('.switch-id-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Switching...';
|
|
try {
|
|
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
|
|
toast('Identity switched — reloading...');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
|
|
});
|
|
});
|
|
|
|
// Wire delete buttons
|
|
list.querySelectorAll('.delete-id-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Delete this identity? This cannot be undone.')) return;
|
|
try {
|
|
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
|
|
toast('Identity deleted');
|
|
loadIdentities();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
list.innerHTML = `<p class="empty-hint">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
$('#create-identity-btn').addEventListener('click', () => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
overlay.style.cursor = 'default';
|
|
overlay.innerHTML = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
|
|
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
|
|
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('#new-id-create').addEventListener('click', async () => {
|
|
const name = overlay.querySelector('#new-id-name').value.trim();
|
|
if (!name) { toast('Name is required'); return; }
|
|
try {
|
|
const nodeId = await invoke('create_identity', { name });
|
|
toast(`Identity created: ${nodeId.substring(0, 12)}`);
|
|
overlay.remove();
|
|
loadIdentities();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
overlay.querySelector('#new-id-cancel').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
});
|
|
|
|
$('#import-identity-btn').addEventListener('click', () => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-lightbox';
|
|
overlay.style.cursor = 'default';
|
|
overlay.innerHTML = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
|
|
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
|
|
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
|
|
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
|
|
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('#import-id-go').addEventListener('click', async () => {
|
|
const keyHex = overlay.querySelector('#import-id-key').value.trim();
|
|
const name = overlay.querySelector('#import-id-name').value.trim() || 'Imported';
|
|
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
|
|
try {
|
|
const nodeId = await invoke('import_identity_key', { keyHex, name });
|
|
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
|
|
overlay.remove();
|
|
loadIdentities();
|
|
} catch (e) { toast('Error: ' + e); }
|
|
});
|
|
overlay.querySelector('#import-id-cancel').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
});
|
|
|
|
// --- 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';
|
|
|
|
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 `<button class="persona-pill" data-id="${p.id}"
|
|
style="padding:0.25rem 0.6rem;border-radius:999px;border:1px solid ${active ? '#7fdbca' : '#444'};background:${bg};color:${fg};font-size:0.7rem;white-space:nowrap;cursor:pointer">${escapeHtml(p.label)}</button>`;
|
|
}).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 = '<p class="empty-hint" style="text-align:center">No posting personas yet.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = personasCache.map(p => {
|
|
const label = p.displayName || '(unnamed)';
|
|
const defaultTag = p.isDefault ? ' <span style="color:#7fdbca;font-size:0.65rem">(default)</span>' : '';
|
|
const setDefaultBtn = p.isDefault
|
|
? ''
|
|
: `<button class="btn btn-ghost btn-sm set-default-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem">Set default</button>`;
|
|
const deleteBtn = p.isDefault
|
|
? ''
|
|
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
|
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
|
|
<div style="min-width:0;flex:1">
|
|
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
|
|
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
|
|
</div>
|
|
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${setDefaultBtn}${deleteBtn}</div>
|
|
</div>`;
|
|
}).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 `<option value="${p.nodeId}">${escapeHtml(label)}${tag}</option>`;
|
|
}).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 = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
|
|
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
|
|
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
|
|
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
|
|
</div>
|
|
</div>`;
|
|
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 = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
|
|
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
|
|
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
|
|
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
|
|
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key — safe to share)</label>
|
|
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
|
|
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
|
|
</div>
|
|
<div style="margin-bottom:0.75rem">
|
|
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
|
|
<div style="display:flex;gap:0.25rem;margin-top:0.25rem">
|
|
<input id="export-output-dir" type="text" value="Downloads" style="flex:1;font-size:0.8rem" />
|
|
<button class="btn btn-ghost btn-sm" id="export-browse">Browse</button>
|
|
</div>
|
|
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-primary btn-sm" id="export-go">Export</button>
|
|
<button class="btn btn-ghost btn-sm" id="export-cancel">Cancel</button>
|
|
</div>
|
|
<div id="export-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
|
|
overlay.querySelector('#export-go').addEventListener('click', async () => {
|
|
const scope = overlay.querySelector('input[name="export-scope"]:checked')?.value;
|
|
if (!scope) { toast('Select a scope'); return; }
|
|
let outputDir = overlay.querySelector('#export-output-dir').value.trim() || 'Downloads';
|
|
// Resolve relative to home
|
|
if (!outputDir.startsWith('/')) {
|
|
// On desktop, use a reasonable default
|
|
outputDir = outputDir;
|
|
}
|
|
const status = overlay.querySelector('#export-status');
|
|
status.textContent = 'Exporting...';
|
|
overlay.querySelector('#export-go').disabled = true;
|
|
try {
|
|
const result = await invoke('export_data', { scope, outputDir });
|
|
status.textContent = result;
|
|
// 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 = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
|
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
|
|
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
|
|
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
|
|
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
|
|
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
|
|
</div>
|
|
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
|
|
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
|
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="as_personas" checked /> Restore as personas — posts keep their original authors; source's keys become personas you can post as</label>
|
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as a separate identity (own data dir)</label>
|
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" /> Public posts only (no keys imported)</label>
|
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="merge_key" /> Consolidate under current default persona (requires source key)</label>
|
|
<div id="merge-key-input" style="display:none;margin-top:0.4rem">
|
|
<input id="import-merge-key" type="text" placeholder="Original identity key (64 hex chars)" maxlength="64" style="width:100%;font-family:monospace;font-size:0.7rem" />
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
|
<button class="btn btn-ghost btn-sm" id="import-preview">Preview</button>
|
|
<button class="btn btn-primary btn-sm" id="import-go" style="display:none">Import</button>
|
|
<button class="btn btn-ghost btn-sm" id="import-cancel">Cancel</button>
|
|
</div>
|
|
<div id="import-status" style="margin-top:0.5rem;font-size:0.75rem;color:#888"></div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
|
|
overlay.querySelector('#import-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 = `
|
|
<div><strong>Node:</strong> ${s.node_id.substring(0, 16)}...</div>
|
|
<div><strong>Posts:</strong> ${s.post_count} <strong>Blobs:</strong> ${s.blob_count}</div>
|
|
<div><strong>Has key:</strong> ${s.has_identity_key ? 'Yes' : 'No'} <strong>Follows:</strong> ${s.has_follows ? 'Yes' : 'No'}</div>
|
|
<div><strong>Exported:</strong> ${new Date(s.export_date).toLocaleDateString()}</div>`;
|
|
overlay.querySelector('#import-action-box').style.display = 'block';
|
|
overlay.querySelector('#import-go').style.display = '';
|
|
// Hide "add as identity" if no key in export
|
|
if (!s.has_identity_key) {
|
|
const addIdRadio = overlay.querySelector('input[value="add_identity"]');
|
|
addIdRadio.disabled = true;
|
|
addIdRadio.parentElement.style.opacity = '0.4';
|
|
}
|
|
status.textContent = '';
|
|
} catch (e) {
|
|
status.textContent = 'Error: ' + e;
|
|
}
|
|
});
|
|
|
|
// Show/hide merge key input based on selected action
|
|
overlay.querySelectorAll('input[name="import-action"]').forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
const mergeInput = overlay.querySelector('#merge-key-input');
|
|
mergeInput.style.display = radio.value === 'merge_key' && radio.checked ? 'block' : 'none';
|
|
});
|
|
});
|
|
|
|
overlay.querySelector('#import-go').addEventListener('click', async () => {
|
|
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
|
|
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
|
|
if (!action) { toast('Select an import action'); return; }
|
|
const status = overlay.querySelector('#import-status');
|
|
status.textContent = 'Importing...';
|
|
overlay.querySelector('#import-go').disabled = true;
|
|
try {
|
|
let result;
|
|
if (action === '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]) =>
|
|
`<button class="notif-opt${v === value ? ' active' : ''}" data-key="${key}" data-value="${v}">${text}</button>`
|
|
).join('');
|
|
return `<div class="notif-row"><span class="notif-label">${label}</span><div class="notif-opts">${btns}</div></div>`;
|
|
}
|
|
|
|
const html = `
|
|
${btnGroup('Messages', 'notif_messages', msgVal, [['off','Off'],['on','On'],['preview','Preview']])}
|
|
${btnGroup('Posts', 'notif_posts', postVal, [['off','Off'],['follows','Follows'],['recommended','Recommended'],['popular','Popular']])}
|
|
${btnGroup('Reactions & Comments', 'notif_reacts', reactVal, [['off','Off'],['on','On']])}
|
|
${btnGroup('Nearby Users', 'notif_nearby', nearbyVal, [['off','Off'],['on','On']])}
|
|
<p class="empty-hint" style="margin-top:0.75rem">Changes are saved automatically.</p>`;
|
|
|
|
openPopover('Notifications', html, {
|
|
onOpen() {
|
|
document.querySelectorAll('.notif-opt').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const key = btn.dataset.key;
|
|
const value = btn.dataset.value;
|
|
// Update active state
|
|
btn.closest('.notif-opts').querySelectorAll('.notif-opt').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
await invoke('set_setting', { key, value });
|
|
toast('Setting saved');
|
|
});
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
resetDataBtn.addEventListener('click', async () => {
|
|
if (!confirm('This will delete all posts, peers, and settings. Your identity key will be preserved. Continue?')) return;
|
|
if (!confirm('Are you sure? This cannot be undone.')) return;
|
|
resetDataBtn.disabled = true;
|
|
try {
|
|
const result = await invoke('reset_data');
|
|
toast(result);
|
|
resetDataBtn.textContent = 'Restart app to apply';
|
|
} catch (e) {
|
|
toast('Error: ' + e);
|
|
resetDataBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
setupBtn.addEventListener('click', doSetupName);
|
|
setupName.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doSetupName();
|
|
});
|
|
|
|
// --- Init ---
|
|
async function init() {
|
|
updateCharCount();
|
|
_lastFeedViewMs = Date.now();
|
|
updateNetworkIndicator().catch(() => {});
|
|
|
|
// Welcome screen — stagger count reveals every 2 seconds
|
|
let _welcomeTick = 0;
|
|
let _welcomeValues = [0, 0, 0, 0, 0];
|
|
const welcomeFields = ['welcome-connections', 'welcome-posts', 'welcome-messages', 'welcome-reacts', 'welcome-comments'];
|
|
|
|
// Fetch data in background (non-blocking — updates _welcomeValues + tab badges + notifications)
|
|
const welcomeFetch = () => {
|
|
invoke('get_network_summary').then(info => {
|
|
_welcomeValues[0] = info.totalConnections || 0;
|
|
}).catch(() => {});
|
|
invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }).then(b => {
|
|
_welcomeValues[1] = b.newFeed || 0;
|
|
_welcomeValues[2] = b.unreadMessages || 0;
|
|
_welcomeValues[3] = b.newReacts || 0;
|
|
_welcomeValues[4] = b.newComments || 0;
|
|
// Update tab badges from welcome screen
|
|
updateTabBadge('feed', b.newFeed || 0);
|
|
updateTabBadge('myposts', b.newEngagement || 0);
|
|
updateTabBadge('messages', b.unreadMessages || 0);
|
|
// Ticker + notifications only after user leaves welcome screen
|
|
// (welcome page already shows these counts directly)
|
|
}).catch(() => {});
|
|
};
|
|
// Stagger reveals — one field every 2 seconds (first fetch happens on first tick)
|
|
let _welcomeRevealed = 0;
|
|
const welcomeInterval = setInterval(() => {
|
|
if (currentTab !== 'welcome') {
|
|
clearInterval(welcomeInterval);
|
|
return;
|
|
}
|
|
// Reveal next field
|
|
if (_welcomeRevealed < welcomeFields.length) {
|
|
const el = document.getElementById(welcomeFields[_welcomeRevealed]);
|
|
if (el) el.textContent = _welcomeValues[_welcomeRevealed];
|
|
_welcomeRevealed++;
|
|
}
|
|
// Update all revealed fields with latest data
|
|
welcomeFetch();
|
|
for (let i = 0; i < _welcomeRevealed; i++) {
|
|
const el = document.getElementById(welcomeFields[i]);
|
|
if (el) el.textContent = _welcomeValues[i];
|
|
}
|
|
}, 2000);
|
|
|
|
// Ready button — click to go to feed
|
|
const readyBtn = document.getElementById('welcome-ready-btn');
|
|
const readyBar = document.getElementById('welcome-ready-bar');
|
|
if (readyBtn) {
|
|
readyBtn.addEventListener('click', () => {
|
|
if (readyBtn.disabled) return;
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
const feedTab = document.querySelector('.tab[data-tab="feed"]');
|
|
if (feedTab) feedTab.classList.add('active');
|
|
document.getElementById('view-feed').classList.add('active');
|
|
currentTab = 'feed';
|
|
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(() => {});
|
|
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 = `
|
|
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:2rem;max-width:360px;width:90%;text-align:center">
|
|
<h2 style="color:#7fdbca;margin:0 0 0.5rem">Welcome to ItsGoin</h2>
|
|
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
|
|
<div style="display:flex;flex-direction:column;gap:0.75rem">
|
|
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
|
|
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
|
|
</div>
|
|
</div>`;
|
|
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 = '<span>This identity may be active on another device. Sync paused.</span><button id="dup-override-btn" style="background:#fff;color:#c0392b;border:none;border-radius:4px;padding:0.2rem 0.6rem;font-size:0.75rem;cursor:pointer">Continue Anyway</button>';
|
|
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();
|