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

@ -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,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 = `<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>
<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="header-row">
<h1>ItsGoin</h1>
<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; }