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
140
frontend/app.js
140
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,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue