From 24b78a8d41771e8c4fc90383cf9997c18d63d4f0 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 20 Mar 2026 23:09:57 -0400 Subject: [PATCH] v0.3.6: Network indicator, tab badges, message read tracking, UI cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: - Network indicator dot (black/red/yellow/green) + capability labels (Public, Server) - Tab badges: Feed (new posts), My Posts (new engagement), People (online), Messages (unread) - Stats bar removed — contextual counts in tab labels - Message thread popup variable scoping fix Message read tracking: - mark_conversation_read on popover open, close, and message send - Prevents re-notification of already-seen messages after app restart Network: - Added has_public_v6(), has_upnp() getters on Network - NetworkSummaryDto includes hasPublicV6, hasPublicV4, hasUpnp Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/network.rs | 11 +++ crates/tauri-app/src/lib.rs | 6 ++ frontend/app.js | 140 +++++++++++++++++++++++++++--------- frontend/index.html | 11 +-- frontend/style.css | 12 +++- website/design.html | 2 +- website/download.html | 3 + 7 files changed, 142 insertions(+), 43 deletions(-) diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 16c6e0d..bf93e87 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -359,6 +359,17 @@ impl Network { self.has_upnp_tcp } + /// Whether this node has a public IPv6 address. + pub fn has_public_v6(&self) -> bool { + self.has_public_v6 + } + + /// Whether this node has UPnP mapping (UDP or TCP). + pub fn has_upnp(&self) -> bool { + self.upnp_mapping.is_some() || self.has_upnp_tcp + } + + /// Get external HTTP address string for InitialExchange advertisement. pub fn http_addr(&self) -> Option { if let Some(ref mapping) = self.upnp_mapping { diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 9477f72..52789e2 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1441,6 +1441,9 @@ struct NetworkSummaryDto { total_connections: usize, n2_distinct: usize, n3_distinct: usize, + has_public_v6: bool, + has_public_v4: bool, + has_upnp: bool, } #[tauri::command] @@ -1470,6 +1473,9 @@ async fn get_network_summary(state: State<'_, AppState>) -> Result
'; } +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; - 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(); - } + const base = TAB_BASE_LABELS[tabName] || tabName; + tab.textContent = count > 0 ? `${base} (${count})` : base; +} + +let _lastFeedViewMs = 0; +let _lastMyPostsViewMs = 0; + +async function updateNetworkIndicator() { + try { + const info = await invoke('get_network_summary'); + const dot = $('#net-dot'); + const labels = $('#net-labels'); + if (!dot) return; + const total = info.totalConnections || 0; + dot.className = total === 0 ? 'net-black' + : total === 1 ? 'net-red' + : total <= 10 ? 'net-yellow' + : 'net-green'; + dot.id = 'net-dot'; + // Build labels + let labelHtml = ''; + if (info.hasPublicV6) labelHtml += 'Public'; + if (info.hasPublicV4 || info.hasUpnp) labelHtml += 'Server'; + if (labels) labels.innerHTML = labelHtml; + } catch (_) {} } // --- Compose auto-grow --- @@ -608,14 +621,8 @@ async function loadNodeInfo() { async function loadStats() { try { - const s = await invoke('get_stats'); - statPosts.textContent = `${s.postCount} posts`; - statFollows.textContent = `${s.followCount} following`; - // Show people count as follows + audience, with "+" if more discoverable peers exist - const peopleCount = s.followCount; - const hasMore = s.peerCount > s.followCount; - statPeers.textContent = `${peopleCount}${hasMore ? '+' : ''} people`; - updateTabBadge('people', peopleCount); + await invoke('get_stats'); + // Stats bar removed — tab badges show contextual counts } catch (e) { console.error('loadStats:', e); } @@ -680,6 +687,14 @@ async function loadFeed(force) { } catch (e) { feedList.innerHTML = `

Error: ${e}

`; } + // Update feed badge if not currently viewing feed + if (currentTab !== 'feed' && _lastFeedViewMs > 0) { + try { + const allPosts = await invoke('get_feed'); + const newCount = allPosts.filter(p => p.intentKind !== 'direct' && !p.isMe && p.timestampMs > _lastFeedViewMs).length; + updateTabBadge('feed', newCount); + } catch (_) {} + } } async function loadMyPosts(force) { @@ -710,12 +725,32 @@ async function loadMyPosts(force) { } } } - // Mark all visible own posts' engagement as seen (DB-backed) - 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(() => {}); + // Count posts with new engagement (before marking as seen) + if (currentTab !== 'myposts') { + let newEngagement = 0; + 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) { + try { + const seen = await invoke('get_seen_engagement', { postId: p.id }); + if (totalReacts > (seen.seenReactCount || 0) || totalComments > (seen.seenCommentCount || 0)) { + newEngagement++; + } + } catch (_) { newEngagement++; } + } + } + updateTabBadge('myposts', newEngagement); + } + + // Mark all visible own posts' engagement as seen (DB-backed) when viewing tab + if (currentTab === 'myposts') { + for (const p of mine) { + const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0); + const totalComments = p.commentCount || 0; + if (totalReacts > 0 || totalComments > 0) { + invoke('mark_post_seen', { postId: p.id, reactCount: totalReacts, commentCount: totalComments }).catch(() => {}); + } } } } catch (e) { @@ -862,9 +897,13 @@ async function loadMessages(force) { 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 = thread.posts.filter(p => { - const enc = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0); + 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); @@ -888,7 +927,7 @@ async function loadMessages(force) { invoke('mark_conversation_read', { partnerId }).catch(() => {}); // Mark incoming encrypted messages as "seen" - for (const p of thread.posts) { + for (const p of threadPosts) { if (!p.isMe && threadPostIds.includes(p.id)) { invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {}); } @@ -921,6 +960,7 @@ async function loadMessages(force) { $('#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(); @@ -934,6 +974,10 @@ async function loadMessages(force) { }; $('#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(() => {}); } }); }); @@ -950,7 +994,14 @@ async function loadMessages(force) { attachFollowHandlers(messageRequestsList); } - updateTabBadge('messages', requests.length); + // 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 = `

Error: ${e}

`; } @@ -1163,6 +1214,8 @@ async function loadFollows() { ? others.filter(f => !online.includes(f)) : []; + updateTabBadge('people', online.length); + let html = ''; if (online.length > 0) { html += `
Following: Online (${online.length})
`; @@ -2311,6 +2364,8 @@ async function doSendDM() { 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); @@ -2684,10 +2739,16 @@ document.querySelectorAll('.tab').forEach(tab => { 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') { loadMyPosts(true); loadCircles(); } + if (target === 'myposts') { + _lastMyPostsViewMs = Date.now(); + updateTabBadge('myposts', 0); + loadMyPosts(true); loadCircles(); + } if (target === 'people') { if (!followsList.children.length) followsList.innerHTML = renderLoading(); loadFollows(); loadAudience(); @@ -2927,12 +2988,21 @@ async function init() { setupName.focus(); } - // Auto-refresh every 10 seconds (feed, posts, people, stats) + // Initialize feed view timestamp + _lastFeedViewMs = Date.now(); + + // Initial network indicator + updateNetworkIndicator(); + + // Auto-refresh every 10 seconds setInterval(() => { if (currentTab === 'feed') loadFeed(); if (currentTab === 'myposts') loadMyPosts(); if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); } - loadStats(); + updateNetworkIndicator(); + // Update badges for non-active tabs + if (currentTab !== 'feed') loadFeed(); + if (currentTab !== 'myposts') loadMyPosts(); }, 10000); // Tiered DM polling: frequency based on recency of last message diff --git a/frontend/index.html b/frontend/index.html index babd113..a34fc82 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,11 +18,12 @@
-

ItsGoin

-
- 0 posts | - 0 peers | - 0 following +
+

ItsGoin

+
+ + +
diff --git a/frontend/style.css b/frontend/style.css index f110982..da7825a 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2,8 +2,16 @@ select option { color: #000 !important; } body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1rem; background: #1a1a2e; color: #e0e0e0; color-scheme: dark; } header { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; } -header h1 { font-size: 1.4rem; color: #7fdbca; } -#stats-bar { font-size: 0.8rem; color: #bbc; margin-top: 0.25rem; } +#header-row { display: flex; justify-content: space-between; align-items: center; } +header h1 { font-size: 1.4rem; color: #7fdbca; margin: 0; } +#net-indicator { display: flex; align-items: center; gap: 0.4rem; } +#net-dot { width: 10px; height: 10px; border-radius: 50%; background: #222; border: 1px solid #444; } +#net-dot.net-black { background: #222; } +#net-dot.net-red { background: #e74c3c; } +#net-dot.net-yellow { background: #f1c40f; } +#net-dot.net-green { background: #22c55e; } +#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; } +.net-label { background: #2a2a40; padding: 0.1rem 0.35rem; border-radius: 3px; color: #aab; } /* Setup overlay */ .overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 20, 0.92); display: flex; align-items: center; justify-content: center; z-index: 200; } diff --git a/website/design.html b/website/design.html index aa728af..46a5447 100644 --- a/website/design.html +++ b/website/design.html @@ -44,7 +44,7 @@

This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.

Changelog -

v0.3.6 (2026-03-20): Active CDN replication — all devices proactively replicate recent posts to peers (desktops > anchors > phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix — BlobHeader JSON rebuilt after diff ops. Tombstone system — deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first.

+

v0.3.6 (2026-03-20): Active CDN replication — all devices proactively replicate recent posts to peers (desktops > anchors > phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset, phones 100MB/1GB, desktops 200MB/2GB, anchors 200MB/1GB. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix — BlobHeader JSON rebuilt after diff ops. Tombstone system — deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first. Network indicator — header dot (black/red/yellow/green) + capability labels. Tab badges — contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.

v0.3.5 (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt & comment slots — private posts carry noise-prefilled encrypted slots in BlobHeader for delivery/read/react receipts and private comments; CDN-propagated as opaque bytes; slot key derived from post CEK; 3 new BlobHeaderDiffOps (WriteReceiptSlot, WriteCommentSlot, AddCommentSlots). Message UI — DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.

v0.3.4 (2026-03-18): Comment edit & delete with trust-based propagation. Native notifications via Tauri plugin (messages, posts, reactions, comments). Forward-compatible BlobHeaderDiffOp::Unknown variant. Following Online/Offline lightbox. Comment threading scoping fix. Dropdown text legibility fix. Mobile hamburger nav for website.

v0.3.3 (2026-03-16): Connection rate limiting — incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning — PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness — TTL 7d→5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery — 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix — nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking — post_upstream table records post source for engagement diff routing toward author. Video preload fix — share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.

diff --git a/website/download.html b/website/download.html index 74f9a16..34aadac 100644 --- a/website/download.html +++ b/website/download.html @@ -73,6 +73,9 @@
v0.3.6 — March 20, 2026
    +
  • Network indicator — Header shows connection status dot (black/red/yellow/green for 0/1/2-10/11+ connections) with capability labels (Public, Server).
  • +
  • Tab badges — Feed shows new post count, My Posts shows new engagement count, People shows online count, Messages shows unread conversation count. Numbers only, no labels.
  • +
  • Message read tracking — Conversations marked as read on open, close, and send. Prevents re-notification of already-seen messages.
  • Active CDN replication — All devices proactively request replication of their recent posts (<72h) to connected peers. Targets prioritized: desktops > anchors > phones. Graceful with small networks (1 peer = 1 replica). ReplicationRequest/Response (0xE1/0xE2) wire messages.
  • Device roles — Nodes classified as Intermittent (phones), Available (desktops), or Persistent (anchors). Advertised in InitialExchange. Influences replication target selection and budget defaults.
  • Bandwidth budgets — Hourly replication budget (content pulled to cache) and delivery budget (content served). Phones: 100MB/1GB, Desktops: 200MB/2GB, Anchors: 200MB/1GB. Auto-reset hourly. Blob serving declines when delivery budget exhausted.