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:
parent
a7e632de88
commit
24b78a8d41
7 changed files with 142 additions and 43 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
130
frontend/app.js
130
frontend/app.js
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 — 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 — 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.</p>
|
||||
<p style="margin-top: 0.5rem;"><strong>v0.3.6</strong> (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.</p>
|
||||
<p><strong>v0.3.5</strong> (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.</p>
|
||||
<p><strong>v0.3.4</strong> (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.</p>
|
||||
<p><strong>v0.3.3</strong> (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.</p>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@
|
|||
<div class="changelog">
|
||||
<div class="changelog-date">v0.3.6 — March 20, 2026</div>
|
||||
<ul>
|
||||
<li><strong>Network indicator</strong> — 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> — 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> — Conversations marked as read on open, close, and send. Prevents re-notification of already-seen messages.</li>
|
||||
<li><strong>Active CDN replication</strong> — 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.</li>
|
||||
<li><strong>Device roles</strong> — 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> — 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue