v0.3.6: Network indicator, tab badges, message read tracking, UI cleanup

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 23:09:57 -04:00
parent a7e632de88
commit 24b78a8d41
7 changed files with 142 additions and 43 deletions

View file

@ -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<String> {
if let Some(ref mapping) = self.upnp_mapping {

View file

@ -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<NetworkSummar
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
})
}

View file

@ -29,9 +29,7 @@ const circleSelect = $('#circle-select');
const circleNameInput = $('#circle-name-input');
const createCircleBtn = $('#create-circle-btn');
const circlesList = $('#circles-list');
const statPosts = $('#stat-posts');
const statPeers = $('#stat-peers');
const statFollows = $('#stat-follows');
// Stats bar removed — contextual counts shown in tab badges
const toastEl = $('#toast');
const profileNameInput = $('#profile-name');
const profileBioInput = $('#profile-bio');
@ -542,20 +540,35 @@ 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;
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 += '<span class="net-label">Public</span>';
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
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 = `<p class="status-err">Error: ${e}</p>`;
}
// 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,7 +725,26 @@ async function loadMyPosts(force) {
}
}
}
// Mark all visible own posts' engagement as seen (DB-backed)
// 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;
@ -718,6 +752,7 @@ async function loadMyPosts(force) {
invoke('mark_post_seen', { postId: p.id, reactCount: totalReacts, commentCount: totalComments }).catch(() => {});
}
}
}
} catch (e) {
myPostsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
@ -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 = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
@ -1163,6 +1214,8 @@ async function loadFollows() {
? others.filter(f => !online.includes(f))
: [];
updateTabBadge('people', online.length);
let html = '';
if (online.length > 0) {
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
@ -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

View file

@ -18,11 +18,12 @@
</div>
<header>
<div id="header-row">
<h1>ItsGoin</h1>
<div id="stats-bar">
<span id="stat-posts">0 posts</span> |
<span id="stat-peers">0 peers</span> |
<span id="stat-follows">0 following</span>
<div id="net-indicator">
<span id="net-dot"></span>
<span id="net-labels"></span>
</div>
</div>
</header>

View file

@ -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; }

View file

@ -44,7 +44,7 @@
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p>
<div class="card" style="margin-top: 1rem;">
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
<p style="margin-top: 0.5rem;"><strong>v0.3.6</strong> (2026-03-20): Active CDN replication &mdash; all devices proactively replicate recent posts to peers (desktops &gt; anchors &gt; 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 &mdash; BlobHeader JSON rebuilt after diff ops. Tombstone system &mdash; 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.</p>
<p style="margin-top: 0.5rem;"><strong>v0.3.6</strong> (2026-03-20): Active CDN replication &mdash; all devices proactively replicate recent posts to peers (desktops &gt; anchors &gt; 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 &mdash; BlobHeader JSON rebuilt after diff ops. Tombstone system &mdash; 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 &mdash; header dot (black/red/yellow/green) + capability labels. Tab badges &mdash; contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.</p>
<p><strong>v0.3.5</strong> (2026-03-20): Private blob encryption &mdash; 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 &mdash; attachments eagerly fetched after post pull for offline availability. Crypto refactoring &mdash; extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering &mdash; feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt &amp; comment slots &mdash; 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 &mdash; DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.</p>
<p><strong>v0.3.4</strong> (2026-03-18): Comment edit &amp; 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.</p>
<p><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting &mdash; incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning &mdash; PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness &mdash; TTL 7d&rarr;5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery &mdash; 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix &mdash; nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking &mdash; post_upstream table records post source for engagement diff routing toward author. Video preload fix &mdash; 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.</p>

View file

@ -73,6 +73,9 @@
<div class="changelog">
<div class="changelog-date">v0.3.6 &mdash; March 20, 2026</div>
<ul>
<li><strong>Network indicator</strong> &mdash; Header shows connection status dot (black/red/yellow/green for 0/1/2-10/11+ connections) with capability labels (Public, Server).</li>
<li><strong>Tab badges</strong> &mdash; Feed shows new post count, My Posts shows new engagement count, People shows online count, Messages shows unread conversation count. Numbers only, no labels.</li>
<li><strong>Message read tracking</strong> &mdash; Conversations marked as read on open, close, and send. Prevents re-notification of already-seen messages.</li>
<li><strong>Active CDN replication</strong> &mdash; All devices proactively request replication of their recent posts (&lt;72h) to connected peers. Targets prioritized: desktops &gt; anchors &gt; phones. Graceful with small networks (1 peer = 1 replica). ReplicationRequest/Response (0xE1/0xE2) wire messages.</li>
<li><strong>Device roles</strong> &mdash; Nodes classified as Intermittent (phones), Available (desktops), or Persistent (anchors). Advertised in InitialExchange. Influences replication target selection and budget defaults.</li>
<li><strong>Bandwidth budgets</strong> &mdash; 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.</li>