diff --git a/Cargo.lock b/Cargo.lock index cb0d0b5..c7229e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2746,13 +2746,14 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "base64 0.22.1", "dirs 5.0.1", "hex", "itsgoin-core", + "notify-rust", "open", "serde", "serde_json", diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 80ae712..d4ea764 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -3818,13 +3818,16 @@ impl ConnectionManager { }; tokio::spawn(async move { if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &requester_addrs, our_nat_profile, peer_nat_profile).await { - // Register as session so the connection is actually used + // Register as session with the peer's address for relay introduction + let remote_sock = requester_addrs.iter() + .filter_map(|a| a.parse::().ok()) + .find(|s| crate::network::is_publicly_routable(s)); let mut cm = conn_mgr_arc.lock().await; if cm.is_connected(&requester) { // Initiator already connected to us (their punch succeeded first) return; } - cm.add_session(requester, conn, SessionReachMethod::HolePunch, None); + cm.add_session(requester, conn, SessionReachMethod::HolePunch, remote_sock); cm.mark_reachable(&requester); cm.log_activity( ActivityLevel::Info, @@ -5664,6 +5667,12 @@ impl ConnectionManager { write_typed_message(&mut send, MessageType::BlobHeaderResponse, &response).await?; } MessageType::ReplicationRequest => { + // Limit to 3 concurrent replication handlers to prevent overload + static REPLICATION_SEMAPHORE: std::sync::LazyLock = + std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(3)); + let _permit = REPLICATION_SEMAPHORE.acquire().await + .map_err(|_| anyhow::anyhow!("replication semaphore closed"))?; + let payload: ReplicationRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let (accepted, rejected, needs_pull) = { let cm = conn_mgr.lock().await; @@ -5711,9 +5720,84 @@ impl ConnectionManager { needs_pull = needs_pull_count, "Handled replication request" ); - // Posts we accepted but don't have will be fetched on the next pull cycle - // from the requester (they have these posts since they asked us to hold them). - // No explicit pull spawn needed — the periodic pull cycle handles it. + // Actively fetch posts we accepted but don't have from the requester + if !needs_pull.is_empty() { + let cm_arc = conn_mgr.clone(); + let sender = remote_node_id; + tokio::spawn(async move { + let conn = { + let cm = cm_arc.lock().await; + cm.connections_ref().get(&sender).map(|pc| pc.connection.clone()) + .or_else(|| cm.sessions.get(&sender).map(|sc| sc.connection.clone())) + }; + let Some(conn) = conn else { return }; + let mut fetched = 0usize; + for post_id in &needs_pull { + // PostFetch without holding any lock + let result: anyhow::Result> = async { + let (mut send, mut recv) = conn.open_bi().await?; + let req = crate::protocol::PostFetchRequestPayload { post_id: *post_id }; + write_typed_message(&mut send, MessageType::PostFetchRequest, &req).await?; + send.finish()?; + let msg_type = read_message_type(&mut recv).await?; + if msg_type != MessageType::PostFetchResponse { + return Ok(None); + } + let resp: crate::protocol::PostFetchResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + Ok(resp.post) + }.await; + + if let Ok(Some(sp)) = result { + if crate::content::verify_post_id(&sp.id, &sp.post) { + let attachments = sp.post.attachments.clone(); + let post_author = sp.post.author; + let cm = cm_arc.lock().await; + let storage = cm.storage.lock().await; + let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility); + let prio = storage.get_post_upstreams(&sp.id).map(|v| v.len() as u8).unwrap_or(0); + let _ = storage.add_post_upstream(&sp.id, &sender, prio); + let blob_store = cm.blob_store.clone(); + drop(storage); + drop(cm); + fetched += 1; + + // Fetch blobs for this post from the requester + for att in &attachments { + if blob_store.has(&att.cid) { continue; } + let blob_result: anyhow::Result<()> = async { + let (mut bs, mut br) = conn.open_bi().await?; + let req = BlobRequestPayload { + cid: att.cid, + requester_addresses: vec![], + }; + write_typed_message(&mut bs, MessageType::BlobRequest, &req).await?; + bs.finish()?; + let mt = read_message_type(&mut br).await?; + if mt != MessageType::BlobResponse { return Ok(()); } + let resp: BlobResponsePayload = read_payload(&mut br, MAX_PAYLOAD).await?; + if resp.found { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.decode(resp.data_b64.as_bytes())?; + blob_store.store(&att.cid, &data)?; + let cm = cm_arc.lock().await; + let storage = cm.storage.lock().await; + let _ = storage.record_blob(&att.cid, post_id, &post_author, data.len() as u64, &att.mime_type, att.size_bytes); + let _ = storage.add_post_upstream(&att.cid, &sender, 0); + } + Ok(()) + }.await; + if let Err(e) = blob_result { + debug!(cid = hex::encode(att.cid), error = %e, "Replication blob fetch failed"); + } + } + } + } + } + if fetched > 0 { + debug!(fetched, peer = hex::encode(sender), "Fetched replicated posts from requester"); + } + }); + } } other => { warn!(msg_type = ?other, "Unexpected message type on bi-stream"); @@ -7189,7 +7273,9 @@ impl ConnectionActor { } if let Ok(routes) = storage.list_social_routes() { for route in &routes { - set.insert(route.node_id); + if route.status == crate::types::SocialStatus::Online { + set.insert(route.node_id); + } } } for nid in &sticky_peers { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index ec082a9..dbea1d4 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -2883,10 +2883,12 @@ impl Storage { for (nid, _, _) in mesh_peers { ids.insert(nid); } - // Add social routes + // Add only ONLINE social routes (not disconnected) let routes = self.list_social_routes()?; for route in routes { - ids.insert(route.node_id); + if route.status == crate::types::SocialStatus::Online { + ids.insert(route.node_id); + } } Ok(ids.into_iter().collect()) } @@ -4870,9 +4872,16 @@ mod tests { preferred_tree: vec![], }).unwrap(); + // Disconnected routes should NOT be in N1 share let n1 = s.build_n1_share().unwrap(); assert!(n1.contains(&peer_a)); - assert!(n1.contains(&follow_b)); + assert!(!n1.contains(&follow_b), "Disconnected social route should not be in N1"); + + // Set to Online — now it should be included + s.set_social_route_status(&follow_b, SocialStatus::Online).unwrap(); + let n1 = s.build_n1_share().unwrap(); + assert!(n1.contains(&peer_a)); + assert!(n1.contains(&follow_b), "Online social route should be in N1"); } #[test] diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index 60b9464..38291b4 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.4.1" +version = "0.4.2" edition = "2021" [lib] @@ -24,3 +24,4 @@ base64 = "0.22" dirs = "5" open = "5" tauri-plugin-notification = "2" +notify-rust = "4" diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index c714dd8..1226695 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -121,6 +121,9 @@ struct StatsDto { struct BadgeCountsDto { new_feed: usize, new_engagement: usize, + unread_messages: usize, + new_reacts: usize, + new_comments: usize, } #[derive(Serialize)] @@ -313,7 +316,18 @@ async fn get_node_info(state: State<'_, AppState>) -> Result Result<(), String> { + #[cfg(not(target_os = "android"))] + { + let _ = notify_rust::Notification::new() + .summary(&title) + .body(&body) + .appname("ItsGoin") + .show(); + } + Ok(()) +} + #[tauri::command] async fn get_cache_stats(state: State<'_, AppState>) -> Result { let node = state.inner(); @@ -1456,7 +1483,47 @@ async fn get_badge_counts( } } - Ok(BadgeCountsDto { new_feed, new_engagement }) + // Unread messages: count conversations with messages newer than last_read + let mut unread_messages = 0usize; + let dm_posts = all_posts.iter().filter(|(id, p, _)| { + matches!( + storage.get_post_intent(id).ok().flatten(), + Some(VisibilityIntent::Direct(_)) + ) || (p.author != node.node_id && matches!( + storage.get_post_with_visibility(id).ok().flatten(), + Some((_, PostVisibility::Encrypted { .. })) + )) + }); + let mut seen_partners = std::collections::HashSet::new(); + for (_id, post, _vis) in dm_posts { + let partner = if post.author == node.node_id { + // sent DM — skip for unread count + continue; + } else { + post.author + }; + if seen_partners.contains(&partner) { continue; } + seen_partners.insert(partner); + let last_read = storage.get_last_read_message(&partner).unwrap_or(0); + if post.timestamp_ms > last_read { + unread_messages += 1; + } + } + + // Count new reacts and comments separately + let mut new_reacts = 0usize; + let mut new_comments = 0usize; + for (id, post, _vis) in &all_posts { + if post.author != node.node_id { continue; } + let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) + .unwrap_or_default().iter().map(|(_, c, _)| *c).sum(); + let total_comments = storage.get_comment_count(id).unwrap_or(0); + let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); + if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; } + if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; } + } + + Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments }) } #[tauri::command] @@ -2112,6 +2179,7 @@ pub fn run() { get_message_receipts, get_message_comments, get_cache_stats, + send_notification, get_setting, set_setting, mark_post_seen, diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 1c18925..9d03507 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.4.1", + "version": "0.4.2", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 43d66aa..806acab 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -51,7 +51,7 @@ let networkSummaryEl = null; // created dynamically inside diagnostics popover const resetDataBtn = $('#reset-data-btn'); // --- State --- -let currentTab = 'feed'; +let currentTab = 'welcome'; let connectString = ''; let myNodeId = ''; const POST_MAX_CHARS = 500; @@ -361,20 +361,39 @@ let _activeNotificationIds = new Set(); async function maybeNotify(title, body, tag) { try { - if (window.__TAURI__?.notification) { - const { isPermissionGranted, requestPermission, sendNotification } = window.__TAURI__.notification; - 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); - } - } else if ('Notification' in window) { - if (Notification.permission === 'default') await Notification.requestPermission(); - if (Notification.permission === 'granted') new Notification(title, { body, tag, silent: false }); + 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 (_) {} } @@ -446,7 +465,7 @@ function relativeTime(timestampMs) { } function peerLabel(nodeId, displayName) { - if (displayName) return displayName; + if (displayName) return `${displayName} (${nodeId.substring(0, 6)})`; return nodeId.substring(0, 12) + '...'; } @@ -579,6 +598,20 @@ function updateTabBadge(tabName, count) { 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'); @@ -596,6 +629,14 @@ async function updateNetworkIndicator() { if (info.hasPublicV6) labelHtml += 'Public'; if (info.hasPublicV4 || info.hasUpnp) labelHtml += 'Server'; if (labels) labels.innerHTML = labelHtml; + // Only show connection ticker on state change + if (typeof updateNetworkIndicator._lastTotal === 'undefined') updateNetworkIndicator._lastTotal = -1; + if (total !== updateNetworkIndicator._lastTotal) { + if (total === 0) showTicker('Connecting...'); + else if (updateNetworkIndicator._lastTotal === 0) showTicker('Connected'); + else if (total > updateNetworkIndicator._lastTotal) showTicker(`${total} peers connected`); + updateNetworkIndicator._lastTotal = total; + } } catch (_) {} } @@ -666,7 +707,18 @@ async function loadFeed(force) { const oldFp = _feedFingerprint; _feedFingerprint = fp; - // Notify on new posts and engagement (DB-backed seen tracking) + // Ticker for new posts from others + if (_notifReady && oldFp) { + const oldIds = new Set(oldFp.split('|').map(s => s.split(':')[0])); + for (const p of posts) { + if (!p.isMe && !oldIds.has(p.id)) { + const name = p.authorName || p.author.substring(0, 8); + showTicker(`New post from ${name}`); + break; // one ticker per cycle + } + } + } + // Notify on engagement (DB-backed seen tracking) if (_notifReady && oldFp) { try { const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on'; @@ -680,10 +732,12 @@ async function loadFeed(force) { if (totalReacts > seen.seenReactCount) { const newReacts = totalReacts - seen.seenReactCount; maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`); + showTicker(`New reaction on your post`); } if (totalComments > seen.seenCommentCount) { const newComments = totalComments - seen.seenCommentCount; maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`); + showTicker(`New comment on your post`); } } } catch (_) {} @@ -696,6 +750,8 @@ async function loadFeed(force) { if (postEl) expandedComments.add(postEl.dataset.postId); }); if (posts.length === 0) { + // Don't lock in empty fingerprint — let next refresh re-render when posts arrive + _feedFingerprint = null; feedList.innerHTML = renderEmptyState( 'Your feed is empty', 'Follow peers on the People tab to see their posts here.' @@ -839,6 +895,7 @@ async function loadMessages(force) { 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}`); } } } @@ -1556,7 +1613,13 @@ async function loadConnections() { } async function loadAllDiagnostics() { - await Promise.all([loadNetworkSummary(), loadConnections(), loadPeers(), loadActivityLog()]); + 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); @@ -1567,13 +1630,7 @@ let activityInterval = null; async function loadActivityLog() { try { const data = await invoke('get_activity_log'); - // Render timers - const timersEl = $('#activity-timers'); - if (timersEl) { - const now = Date.now(); - timersEl.innerHTML = renderTimer('Rebalance', data.rebalanceLastMs, data.rebalanceIntervalSecs, now) - + renderTimer('Anchor Register', data.anchorRegisterLastMs, data.anchorRegisterIntervalSecs, now); - } + // Timers removed — rebalance/anchor register countdowns not useful for users // Render events (newest first) const logEl = $('#activity-log'); if (logEl) { @@ -2800,20 +2857,32 @@ $('#diagnostics-btn').addEventListener('click', () => { -

Timers

-
+ +

Activity Log

-
-

Mesh Connections

-
-

Known Peers

-
`; +
`; openPopover('Network Diagnostics', diagHtml, { onOpen() { // Re-bind dynamic element refs networkSummaryEl = $('#network-summary'); connectionsList = $('#connections-list'); - peersList = $('#peers-list'); + peersList = null; // Known peers removed + // Wire connections toggle + $('#show-connections-btn').addEventListener('click', () => { + const section = $('#connections-section'); + const btn = $('#show-connections-btn'); + if (section.classList.contains('hidden')) { + section.classList.remove('hidden'); + btn.textContent = 'Hide Connections'; + loadConnections(); + } else { + section.classList.add('hidden'); + btn.textContent = 'Show Connections'; + } + }); // Wire action buttons $('#diag-refresh-btn').addEventListener('click', async () => { const btn = $('#diag-refresh-btn'); @@ -2869,10 +2938,38 @@ connectInput.addEventListener('keydown', (e) => { $('#connect-toggle').addEventListener('click', () => { const body = $('#connect-body'); body.classList.toggle('hidden'); - $('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually...' : 'Cancel'; + $('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually' : 'Cancel'; +}); +$('#share-details-btn').addEventListener('click', () => { + const overlay = document.createElement('div'); + overlay.className = 'image-lightbox'; + overlay.style.cursor = 'default'; + let qrSvg = ''; + try { qrSvg = generateQRCodeSVG(connectString, 200); } catch (_) {} + overlay.innerHTML = ` +
+

Share My Details

+
${qrSvg}
+
${escapeHtml(connectString)}
+
+ + +
+
`; + 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(); }); }); syncBtn.addEventListener('click', doSyncAll); -copyBtn.addEventListener('click', async () => { +if (copyBtn) copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(connectString); toast('Connect string copied!'); @@ -2881,7 +2978,7 @@ copyBtn.addEventListener('click', async () => { prompt('Copy your connect string:', connectString); } }); -exportKeyBtn.addEventListener('click', async () => { +if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => { try { const key = await invoke('export_identity'); try { @@ -2909,6 +3006,28 @@ $('#circle-profiles-toggle').addEventListener('click', () => { }); // --- Notifications popover --- +// Text size toggle +const TEXT_SIZE_SCALES = { small: '100%', normal: '150%', large: '200%' }; +// Apply text size immediately (default Normal = 150%) +document.documentElement.style.fontSize = '150%'; +(async () => { + const saved = await invoke('get_setting', { key: 'text_size' }).catch(() => null) || 'normal'; + document.documentElement.style.fontSize = TEXT_SIZE_SCALES[saved] || '150%'; + document.querySelectorAll('.text-size-opt').forEach(b => { + b.classList.toggle('active', b.dataset.size === saved); + }); +})(); +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'); + await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {}); + toast('Text size updated'); + }); +}); + $('#notifications-btn').addEventListener('click', async () => { // Load current settings const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; @@ -2968,41 +3087,84 @@ setupName.addEventListener('keydown', (e) => { // --- Init --- async function init() { - // Backend setup may still be running — retry until state is managed - for (let attempt = 0; attempt < 30; attempt++) { - try { - await invoke('get_node_info'); - break; // backend ready - } catch (e) { - if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; } - await new Promise(r => setTimeout(r, 300)); - } - } - updateCharCount(); - const info = await loadNodeInfo(); - await loadStats(); - await loadFeed(); - await loadMessages(); - // Now safe to fire notifications (initial data loaded, won't spam) - _notifReady = true; - - // Show setup overlay if no profile exists - if (info && !info.hasProfile) { - setupOverlay.classList.remove('hidden'); - setupName.focus(); - } - - // Initialize feed view timestamp _lastFeedViewMs = Date.now(); + updateNetworkIndicator().catch(() => {}); - // Initial network indicator - updateNetworkIndicator(); + // Welcome screen — stagger count reveals every 2 seconds + let _welcomeTick = 0; + let _welcomeValues = [0, 0, 0, 0, 0]; + const welcomeFields = ['welcome-connections', 'welcome-posts', 'welcome-messages', 'welcome-reacts', 'welcome-comments']; + + // Fetch data in background (non-blocking — updates _welcomeValues + tab badges + notifications) + const welcomeFetch = () => { + invoke('get_network_summary').then(info => { + _welcomeValues[0] = info.totalConnections || 0; + }).catch(() => {}); + invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }).then(b => { + _welcomeValues[1] = b.newFeed || 0; + _welcomeValues[2] = b.unreadMessages || 0; + _welcomeValues[3] = b.newReacts || 0; + _welcomeValues[4] = b.newComments || 0; + // Update tab badges from welcome screen + updateTabBadge('feed', b.newFeed || 0); + updateTabBadge('myposts', b.newEngagement || 0); + updateTabBadge('messages', b.unreadMessages || 0); + // Ticker + notifications only after user leaves welcome screen + // (welcome page already shows these counts directly) + }).catch(() => {}); + }; + // Stagger reveals — one field every 2 seconds (first fetch happens on first tick) + let _welcomeRevealed = 0; + const welcomeInterval = setInterval(() => { + if (currentTab !== 'welcome') { + clearInterval(welcomeInterval); + return; + } + // Reveal next field + if (_welcomeRevealed < welcomeFields.length) { + const el = document.getElementById(welcomeFields[_welcomeRevealed]); + if (el) el.textContent = _welcomeValues[_welcomeRevealed]; + _welcomeRevealed++; + } + // Update all revealed fields with latest data + welcomeFetch(); + for (let i = 0; i < _welcomeRevealed; i++) { + const el = document.getElementById(welcomeFields[i]); + if (el) el.textContent = _welcomeValues[i]; + } + }, 2000); + + // Wait for backend in the background, then load node info + (async () => { + for (let attempt = 0; attempt < 30; attempt++) { + try { + await invoke('get_node_info'); + break; + } catch (e) { + if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; } + await new Promise(r => setTimeout(r, 300)); + } + } + const info = await loadNodeInfo(); + if (info && !info.hasProfile) { + setupOverlay.classList.remove('hidden'); + setupName.focus(); + } + // Reload feed now that backend is ready + loadFeed(true).catch(() => {}); + loadMessages(true).catch(() => {}); + })(); + + // Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam) + setTimeout(() => { _notifReady = true; }, 6000); // Auto-refresh every 10 seconds — only the active tab + const _initTime = Date.now(); setInterval(() => { - if (currentTab === 'feed') loadFeed(); - if (currentTab === 'myposts') loadMyPosts(); + const startup = Date.now() - _initTime < 30000; // force during first 30s + if (currentTab === 'feed') loadFeed(startup); + if (currentTab === 'myposts') loadMyPosts(startup); if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); } updateNetworkIndicator(); }, 10000); diff --git a/frontend/index.html b/frontend/index.html index a34fc82..e170984 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,6 +20,7 @@

ItsGoin

+
@@ -29,15 +30,31 @@
+ +
+
+

Welcome back!

+

How's it goin?

+

Connecting and getting updates usually takes a couple minutes.
New things we've found so far:

+
+
-Connections
+
-New Posts
+
-Messages
+
-Reacts
+
-Comments
+
+
+
+ -
+
@@ -116,8 +133,9 @@
-
- +
+ +