per-author feed filter, ignore primitive The old People tab was built on network-layer presence (`is_online`, `last_seen` from the mesh), which was lost when v0.6.1 anonymized the network id from the posting id. Every named follow is authored under a posting id that doesn't appear in the connection-layer tables; the "Online" section listed nobody useful and Discover depended on the same broken signal. Replaced with signals derived from signed content: - Following is sorted by most-recent-post timestamp (the real meaning of "activity" in a post-anonymization world). - Discover lists named peers we've received signed profile posts from (via Phase 2d), filtered by follows / ignores / self. - Click-a-name surfaces a bio modal with View Posts / Follow / Message / Ignore actions. - Author-scoped feed filter (`View Posts` on any person) renders a "Showing posts from X" banner with a Clear button. - Ignore is a new local-only primitive; ignored peers' posts and profiles are excluded everywhere and the ignored list is editable in Settings. Core changes: - New `ignored_peers(node_id, ignored_at)` table + storage helpers (`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`, `is_ignored_peer`). Schema created fresh; no migration since the table is purely additive and empty on prior installs. - All 6 feed-query sites now also exclude `author IN ignored_peers`. - New `Storage::last_activity_for_authors(&[NodeId])` — one batched query returning max post timestamp per author, excluding non-feed intents (Control / Profile / Announcement / GroupKeyDistribute). - New `Storage::list_discoverable_profiles(&self_id)` — named profile rows where node_id is not self, not in follows, not in ignored, and `public_visible = 1`. Sorted by profile `updated_at` DESC. - New `Storage::delete_setting(key)` — missing counterpart to set/get. - Node wrappers: `last_activity_for_follows`, `ignore_peer` (also drops any follow + social route for the ignored peer), `unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`. - `list_follows` Tauri command now sources `last_activity_ms` from the posts-driven batched query rather than the network peer record. Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`, `list_ignored_peers`. Frontend: - Following list: see-new-activity button pattern (staged data + explicit user click to rearrange, so the list doesn't reorder under a tap mid-scroll). Periodic people-tab polling stages + lights up the button; clicking it re-renders. - Discover: rewrites the old peer-table-based list to a profile-post feed. Each card shows name + bio + profile-update age, plus Follow / Posts / Ignore actions. - Bio modal: reuses the existing generic popover. Loads display name + bio via `resolve_display`, shows follow state, offers View Posts / Follow-or-Unfollow / Message / Ignore-or-Unignore. - Author filter: banner renders at the top of the feed when active; clear button restores full feed. Filter state is a single `authorFilterNodeId` field consumed by `filterFeedPosts`. - Settings → Ignored section lists ignored peers with unignore buttons. 124 / 124 core tests pass.
4308 lines
194 KiB
JavaScript
4308 lines
194 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) {
|
|
let filtered = posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
|
|
// Per-author filter (driven by "View Posts" button or bio modal).
|
|
if (authorFilterNodeId) {
|
|
filtered = filtered.filter(p => p.author === authorFilterNodeId);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
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));
|
|
|
|
const filterBanner = authorFilterNodeId
|
|
? `<div id="author-filter-banner" style="padding:0.5rem 0.75rem;background:#1a1a2e;border-left:3px solid #7fdbca;margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">
|
|
<span>Showing posts from <strong>${escapeHtml(authorFilterName || authorFilterNodeId.slice(0, 12))}</strong></span>
|
|
<button id="clear-author-filter" class="btn btn-ghost btn-sm">Clear</button>
|
|
</div>`
|
|
: '';
|
|
|
|
if (posts.length === 0) {
|
|
_feedFingerprint = null;
|
|
const empty = authorFilterNodeId
|
|
? renderEmptyState('No posts from this person', 'They may not have published any visible posts yet.')
|
|
: renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
|
|
feedList.innerHTML = filterBanner + empty;
|
|
if (authorFilterNodeId) {
|
|
const clearBtn = document.getElementById('clear-author-filter');
|
|
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
|
}
|
|
} else {
|
|
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
|
|
if (authorFilterNodeId) {
|
|
const clearBtn = document.getElementById('clear-author-filter');
|
|
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
|
}
|
|
// 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 */ }
|
|
}
|
|
}
|
|
|
|
// Highest lastActivityMs shown at last render. When new activity arrives
|
|
// that beats this, we show a "See new activity" button instead of
|
|
// resorting under the user.
|
|
let _followsDisplayedMaxActivity = 0;
|
|
let _followsLatestMaxActivity = 0;
|
|
let _followsPendingData = null; // cached follows array waiting for user's click
|
|
|
|
async function loadFollows() {
|
|
try {
|
|
// v0.6.2 People-tab rewrite:
|
|
// - Online indicator is gone (network/posting id split hid presence).
|
|
// - Sort by last post timestamp (DESC). Authors we've never seen
|
|
// post go to the bottom.
|
|
// - Don't resort under the user mid-interaction: when new activity
|
|
// arrives, show a "See new activity" button instead.
|
|
const follows = await invoke('list_follows');
|
|
const others = follows.filter(f => f.nodeId !== myNodeId);
|
|
const newMax = others.reduce((m, f) => Math.max(m, f.lastActivityMs || 0), 0);
|
|
|
|
if (_followsDisplayedMaxActivity === 0) {
|
|
_followsDisplayedMaxActivity = newMax;
|
|
}
|
|
_followsLatestMaxActivity = newMax;
|
|
|
|
// If the user has already rendered a list and new activity has come
|
|
// in since, stash the new data and reveal the refresh button.
|
|
if (newMax > _followsDisplayedMaxActivity && followsList.childElementCount > 0) {
|
|
_followsPendingData = others;
|
|
const btn = document.getElementById('follows-refresh-btn');
|
|
if (btn) {
|
|
const delta = others.filter(f => (f.lastActivityMs || 0) > _followsDisplayedMaxActivity).length;
|
|
btn.textContent = `See ${delta} new update${delta === 1 ? '' : 's'}`;
|
|
btn.classList.remove('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
renderFollowsList(others);
|
|
_followsDisplayedMaxActivity = newMax;
|
|
const btn = document.getElementById('follows-refresh-btn');
|
|
if (btn) btn.classList.add('hidden');
|
|
} catch (e) {
|
|
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderFollowsList(others) {
|
|
if (others.length === 0) {
|
|
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Discover named people below or connect manually.')}</div>`;
|
|
updateTabBadge('people', 0);
|
|
return;
|
|
}
|
|
|
|
// Sort by last activity DESC; never-posted authors go to the bottom.
|
|
const sorted = [...others].sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
|
updateTabBadge('people', 0);
|
|
|
|
const renderFollowCard = (f) => {
|
|
const icon = generateIdenticon(f.nodeId, 18);
|
|
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
|
const activity = (f.lastActivityMs || 0) > 0
|
|
? `<span class="last-seen">Last posted ${formatTimeAgo(f.lastActivityMs)}</span>`
|
|
: `<span class="last-seen">No posts yet</span>`;
|
|
const viewBtn = `<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${f.nodeId}" data-name="${label}">Posts</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>`;
|
|
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${f.nodeId}" data-name="${label}">${label}</a></div>
|
|
<div class="peer-card-lastseen">${activity}</div>
|
|
<div class="peer-card-bio"></div>
|
|
<div class="peer-card-actions">${viewBtn} ${msgBtn} ${unfollowBtn}</div>
|
|
</div>`;
|
|
};
|
|
|
|
followsList.innerHTML = sorted.map(renderFollowCard).join('');
|
|
|
|
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; }
|
|
});
|
|
});
|
|
followsList.querySelectorAll('.view-posts-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
openAuthorFeed(btn.dataset.nodeId, btn.dataset.name);
|
|
});
|
|
});
|
|
followsList.querySelectorAll('.bio-link').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
|
});
|
|
});
|
|
|
|
loadPeerBios(followsList);
|
|
}
|
|
|
|
// loadSuggested removed — Suggested Peers section removed from UI
|
|
|
|
async function loadDiscoverPeople() {
|
|
const container = $('#discover-list');
|
|
try {
|
|
// v0.6.2: Discover is driven by signed profile posts we've received
|
|
// via the CDN. No liveness / network-presence dependency — if we
|
|
// hold a profile post with a non-empty display_name from someone
|
|
// we don't follow and haven't ignored, they show up.
|
|
const profiles = await invoke('list_discover');
|
|
if (!profiles || profiles.length === 0) {
|
|
container.innerHTML = renderEmptyState(
|
|
'No new named profiles yet',
|
|
'New people appear here as their signed profile posts propagate through the network.'
|
|
);
|
|
return;
|
|
}
|
|
container.innerHTML = profiles.map(p => {
|
|
const icon = generateIdenticon(p.nodeId, 18);
|
|
const label = escapeHtml(p.displayName);
|
|
const bioLine = p.bio
|
|
? `<div class="peer-card-bio" style="font-size:0.8rem;color:#aaa;margin-top:0.15rem">${escapeHtml(p.bio)}</div>`
|
|
: '';
|
|
const ageLine = p.updatedAtMs
|
|
? `<span class="last-seen">Profile updated ${formatTimeAgo(p.updatedAtMs)}</span>`
|
|
: '';
|
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${p.nodeId}" data-name="${label}">${label}</a></div>
|
|
${bioLine}
|
|
${ageLine ? `<div class="peer-card-lastseen">${ageLine}</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 view-posts-btn" data-node-id="${p.nodeId}" data-name="${label}">Posts</button>
|
|
<button class="btn btn-ghost btn-sm ignore-btn" data-node-id="${p.nodeId}" data-name="${label}">Ignore</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
attachFollowHandlers(container);
|
|
container.querySelectorAll('.view-posts-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => openAuthorFeed(btn.dataset.nodeId, btn.dataset.name));
|
|
});
|
|
container.querySelectorAll('.bio-link').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
|
});
|
|
});
|
|
container.querySelectorAll('.ignore-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm(`Ignore ${btn.dataset.name || 'this peer'}? Their posts and profile will be hidden.`)) return;
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('ignore_peer', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Ignored');
|
|
loadDiscoverPeople();
|
|
loadFeed(true);
|
|
} catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// ---- Bio modal + per-author feed filter ----
|
|
|
|
let authorFilterNodeId = null; // when set, feed shows only this author's posts
|
|
let authorFilterName = null;
|
|
|
|
async function openBioModal(nodeId, preloadedName) {
|
|
const overlay = document.getElementById('popover-overlay');
|
|
const titleEl = document.getElementById('popover-title');
|
|
const bodyEl = document.getElementById('popover-body');
|
|
if (!overlay || !titleEl || !bodyEl) return;
|
|
|
|
titleEl.textContent = preloadedName || (nodeId ? nodeId.slice(0, 12) : 'Profile');
|
|
bodyEl.innerHTML = '<p class="empty-hint">Loading…</p>';
|
|
overlay.classList.remove('hidden');
|
|
|
|
try {
|
|
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
|
|
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
|
const follows = await invoke('list_follows').catch(() => []);
|
|
const ignored = await invoke('list_ignored_peers').catch(() => []);
|
|
const following = follows.some(f => f.nodeId === nodeId);
|
|
const isIgnored = ignored.some(i => i.nodeId === nodeId);
|
|
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
|
|
const bio = (resolved && resolved.bio) || '';
|
|
const icon = generateIdenticon(nodeId, 48);
|
|
|
|
titleEl.textContent = name;
|
|
bodyEl.innerHTML = `
|
|
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
|
|
${icon}
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
|
|
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
|
|
</div>
|
|
</div>
|
|
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
|
|
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
|
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
|
|
${following
|
|
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
|
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
|
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
|
${isIgnored
|
|
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
|
: `<button id="bio-ignore" class="btn btn-danger btn-sm">Ignore</button>`}
|
|
</div>
|
|
`;
|
|
|
|
const close = () => overlay.classList.add('hidden');
|
|
|
|
const vp = document.getElementById('bio-view-posts');
|
|
if (vp) vp.onclick = () => { close(); openAuthorFeed(nodeId, name); };
|
|
|
|
const follow = document.getElementById('bio-follow');
|
|
if (follow) follow.onclick = async () => {
|
|
try { await invoke('follow_node', { nodeIdHex: nodeId }); toast('Followed'); close(); loadFollows(); loadFeed(true); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
};
|
|
const unfollow = document.getElementById('bio-unfollow');
|
|
if (unfollow) unfollow.onclick = async () => {
|
|
try { await invoke('unfollow_node', { nodeIdHex: nodeId }); toast('Unfollowed'); close(); loadFollows(); loadFeed(true); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
};
|
|
const msg = document.getElementById('bio-message');
|
|
if (msg) msg.onclick = () => {
|
|
close();
|
|
// Switch to Messages tab + preselect this recipient if possible.
|
|
const tab = document.querySelector('.tab[data-tab="messages"]');
|
|
if (tab) tab.click();
|
|
const sel = document.getElementById('dm-recipient-select');
|
|
if (sel) {
|
|
for (const opt of sel.options) {
|
|
if (opt.value === nodeId) { sel.value = nodeId; break; }
|
|
}
|
|
}
|
|
};
|
|
const ignore = document.getElementById('bio-ignore');
|
|
if (ignore) ignore.onclick = async () => {
|
|
if (!confirm(`Ignore ${name}? They'll be hidden from feeds and Discover.`)) return;
|
|
try { await invoke('ignore_peer', { nodeIdHex: nodeId }); toast('Ignored'); close(); loadFollows(); loadFeed(true); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
};
|
|
const unignore = document.getElementById('bio-unignore');
|
|
if (unignore) unignore.onclick = async () => {
|
|
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
|
|
catch (e) { toast('Error: ' + e); }
|
|
};
|
|
} catch (e) {
|
|
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
function openAuthorFeed(nodeId, name) {
|
|
authorFilterNodeId = nodeId;
|
|
authorFilterName = name || nodeId.slice(0, 12);
|
|
const feedTab = document.querySelector('.tab[data-tab="feed"]');
|
|
if (feedTab) feedTab.click();
|
|
loadFeed(true);
|
|
}
|
|
|
|
function clearAuthorFilter() {
|
|
authorFilterNodeId = null;
|
|
authorFilterName = null;
|
|
loadFeed(true);
|
|
}
|
|
|
|
// 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();
|
|
// Refresh Discover if its section is already expanded.
|
|
const disc = document.getElementById('discover-body');
|
|
if (disc && !disc.classList.contains('hidden')) loadDiscoverPeople();
|
|
}
|
|
if (target === 'messages') {
|
|
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
|
loadMessages(true); loadDmRecipientOptions();
|
|
clearNotifications('msg-');
|
|
}
|
|
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); }
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- 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();
|
|
});
|
|
|
|
// "See new activity" button in Following: applies staged data and
|
|
// re-renders so the user picks when to rearrange the list.
|
|
{
|
|
const refreshBtn = document.getElementById('follows-refresh-btn');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => {
|
|
if (_followsPendingData) {
|
|
renderFollowsList(_followsPendingData);
|
|
_followsDisplayedMaxActivity = _followsLatestMaxActivity;
|
|
_followsPendingData = null;
|
|
}
|
|
refreshBtn.classList.add('hidden');
|
|
});
|
|
}
|
|
}
|
|
|
|
$('#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';
|
|
|
|
// --- Ignored peers list (Settings) ---
|
|
async function loadIgnored() {
|
|
const container = document.getElementById('ignored-list');
|
|
if (!container) return;
|
|
try {
|
|
const ignored = await invoke('list_ignored_peers');
|
|
if (!ignored || ignored.length === 0) {
|
|
container.innerHTML = '<p class="empty-hint" style="margin:0">No ignored peers.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = ignored.map(i => {
|
|
const label = escapeHtml(i.displayName || i.nodeId.slice(0, 12));
|
|
const icon = generateIdenticon(i.nodeId, 18);
|
|
return `<div class="peer-card" data-node-id="${i.nodeId}">
|
|
<div class="peer-card-row">${icon} ${label}</div>
|
|
<div class="peer-card-actions">
|
|
<button class="btn btn-ghost btn-sm unignore-btn" data-node-id="${i.nodeId}">Unignore</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
container.querySelectorAll('.unignore-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await invoke('unignore_peer', { nodeIdHex: btn.dataset.nodeId });
|
|
toast('Unignored');
|
|
loadIgnored();
|
|
loadFeed(true);
|
|
} catch (e) { toast('Error: ' + e); }
|
|
finally { btn.disabled = false; }
|
|
});
|
|
});
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
|
}
|
|
}
|
|
|
|
// --- Release announcement / upgrade banner ---
|
|
async function loadUpgradeBanner() {
|
|
const banner = document.getElementById('upgrade-banner');
|
|
const titleEl = document.getElementById('upgrade-banner-title');
|
|
const bodyEl = document.getElementById('upgrade-banner-body');
|
|
const btn = document.getElementById('upgrade-banner-btn');
|
|
if (!banner || !titleEl || !bodyEl || !btn) return;
|
|
|
|
let channel = 'stable';
|
|
try { channel = await invoke('get_update_channel'); } catch (_) {}
|
|
|
|
let ann = null;
|
|
try { ann = await invoke('check_release_announcement', { channel }); } catch (_) { return; }
|
|
if (!ann) { banner.classList.add('hidden'); return; }
|
|
|
|
titleEl.textContent = ann.title || `v${ann.version} available`;
|
|
bodyEl.textContent = ann.body || `Upgrade to v${ann.version} (${ann.channel}).`;
|
|
btn.onclick = async () => {
|
|
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
|
|
catch (e) { window.open(ann.downloadUrl, '_blank'); }
|
|
};
|
|
banner.classList.remove('hidden');
|
|
}
|
|
|
|
async function loadUpdateSettings() {
|
|
const statusEl = document.getElementById('update-status');
|
|
let channel = 'stable';
|
|
try { channel = await invoke('get_update_channel'); } catch (_) {}
|
|
const radio = document.querySelector(`input[name="update-channel"][value="${channel}"]`);
|
|
if (radio) radio.checked = true;
|
|
|
|
document.querySelectorAll('input[name="update-channel"]').forEach(el => {
|
|
el.addEventListener('change', async () => {
|
|
try {
|
|
await invoke('set_update_channel', { channel: el.value });
|
|
loadUpgradeBanner().catch(() => {});
|
|
refreshUpdateStatus(el.value);
|
|
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
|
|
});
|
|
});
|
|
const checkBtn = document.getElementById('check-updates-btn');
|
|
if (checkBtn) {
|
|
checkBtn.onclick = async () => {
|
|
const ch = document.querySelector('input[name="update-channel"]:checked')?.value || 'stable';
|
|
await loadUpgradeBanner().catch(() => {});
|
|
refreshUpdateStatus(ch);
|
|
};
|
|
}
|
|
refreshUpdateStatus(channel);
|
|
}
|
|
|
|
async function refreshUpdateStatus(channel) {
|
|
const statusEl = document.getElementById('update-status');
|
|
if (!statusEl) return;
|
|
try {
|
|
const ann = await invoke('check_release_announcement', { channel });
|
|
if (ann) {
|
|
statusEl.innerHTML = `<strong style="color:#7fdbca">v${ann.version} available</strong> — <a href="#" id="status-upgrade-link">Download</a>`;
|
|
const link = document.getElementById('status-upgrade-link');
|
|
if (link) link.onclick = async (e) => {
|
|
e.preventDefault();
|
|
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
|
|
catch (_) { window.open(ann.downloadUrl, '_blank'); }
|
|
};
|
|
} else {
|
|
statusEl.textContent = `You're up to date on the ${channel} channel.`;
|
|
}
|
|
} catch (_) {
|
|
statusEl.textContent = '';
|
|
}
|
|
}
|
|
|
|
async function loadPersonas() {
|
|
try {
|
|
personasCache = await invoke('list_posting_identities') || [];
|
|
} catch (e) {
|
|
personasCache = [];
|
|
console.error('list_posting_identities:', e);
|
|
}
|
|
// Filter out the fresh-install disposable persona. It's auto-created
|
|
// before the user has picked fresh-vs-import, and will be pruned on
|
|
// import if still pristine. Hiding it from the Personas UI stops the
|
|
// user from seeing a ghost "blank" persona during the first-run flow.
|
|
try {
|
|
const hiddenId = await invoke('get_first_run_auto_persona_id');
|
|
if (hiddenId) {
|
|
personasCache = personasCache.filter(p => p.nodeId !== hiddenId);
|
|
}
|
|
} catch (_) {}
|
|
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(() => {});
|
|
// Check for a newer release on the user's selected channel and,
|
|
// if found, surface the upgrade banner on the welcome screen.
|
|
loadUpgradeBanner().catch(() => {});
|
|
const info = await loadNodeInfo();
|
|
// Show first-run chooser only if no profile AND only one identity (auto-created)
|
|
let isFirstRun = false;
|
|
if (info && !info.hasProfile) {
|
|
try {
|
|
const ids = await invoke('list_identities');
|
|
isFirstRun = ids.length <= 1;
|
|
} catch (_) {}
|
|
}
|
|
if (isFirstRun) {
|
|
// First-run: start fresh or import
|
|
const chooser = document.createElement('div');
|
|
chooser.className = 'image-lightbox';
|
|
chooser.style.cursor = 'default';
|
|
chooser.innerHTML = `
|
|
<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();
|