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
|
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.
|
/// Get external HTTP address string for InitialExchange advertisement.
|
||||||
pub fn http_addr(&self) -> Option<String> {
|
pub fn http_addr(&self) -> Option<String> {
|
||||||
if let Some(ref mapping) = self.upnp_mapping {
|
if let Some(ref mapping) = self.upnp_mapping {
|
||||||
|
|
|
||||||
|
|
@ -1441,6 +1441,9 @@ struct NetworkSummaryDto {
|
||||||
total_connections: usize,
|
total_connections: usize,
|
||||||
n2_distinct: usize,
|
n2_distinct: usize,
|
||||||
n3_distinct: usize,
|
n3_distinct: usize,
|
||||||
|
has_public_v6: bool,
|
||||||
|
has_public_v4: bool,
|
||||||
|
has_upnp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -1470,6 +1473,9 @@ async fn get_network_summary(state: State<'_, AppState>) -> Result<NetworkSummar
|
||||||
total_connections: conns.len(),
|
total_connections: conns.len(),
|
||||||
n2_distinct: n2,
|
n2_distinct: n2,
|
||||||
n3_distinct: n3,
|
n3_distinct: n3,
|
||||||
|
has_public_v6: node.network.has_public_v6(),
|
||||||
|
has_public_v4: node.network.is_anchor(),
|
||||||
|
has_upnp: node.network.has_upnp(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
140
frontend/app.js
140
frontend/app.js
|
|
@ -29,9 +29,7 @@ const circleSelect = $('#circle-select');
|
||||||
const circleNameInput = $('#circle-name-input');
|
const circleNameInput = $('#circle-name-input');
|
||||||
const createCircleBtn = $('#create-circle-btn');
|
const createCircleBtn = $('#create-circle-btn');
|
||||||
const circlesList = $('#circles-list');
|
const circlesList = $('#circles-list');
|
||||||
const statPosts = $('#stat-posts');
|
// Stats bar removed — contextual counts shown in tab badges
|
||||||
const statPeers = $('#stat-peers');
|
|
||||||
const statFollows = $('#stat-follows');
|
|
||||||
const toastEl = $('#toast');
|
const toastEl = $('#toast');
|
||||||
const profileNameInput = $('#profile-name');
|
const profileNameInput = $('#profile-name');
|
||||||
const profileBioInput = $('#profile-bio');
|
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>';
|
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) {
|
function updateTabBadge(tabName, count) {
|
||||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
let badge = tab.querySelector('.tab-badge');
|
const base = TAB_BASE_LABELS[tabName] || tabName;
|
||||||
if (count > 0) {
|
tab.textContent = count > 0 ? `${base} (${count})` : base;
|
||||||
if (!badge) {
|
}
|
||||||
badge = document.createElement('span');
|
|
||||||
badge.className = 'tab-badge';
|
let _lastFeedViewMs = 0;
|
||||||
tab.appendChild(badge);
|
let _lastMyPostsViewMs = 0;
|
||||||
}
|
|
||||||
badge.textContent = count;
|
async function updateNetworkIndicator() {
|
||||||
} else if (badge) {
|
try {
|
||||||
badge.remove();
|
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 ---
|
// --- Compose auto-grow ---
|
||||||
|
|
@ -608,14 +621,8 @@ async function loadNodeInfo() {
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const s = await invoke('get_stats');
|
await invoke('get_stats');
|
||||||
statPosts.textContent = `${s.postCount} posts`;
|
// Stats bar removed — tab badges show contextual counts
|
||||||
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);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('loadStats:', e);
|
console.error('loadStats:', e);
|
||||||
}
|
}
|
||||||
|
|
@ -680,6 +687,14 @@ async function loadFeed(force) {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
feedList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
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) {
|
async function loadMyPosts(force) {
|
||||||
|
|
@ -710,12 +725,32 @@ async function loadMyPosts(force) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Mark all visible own posts' engagement as seen (DB-backed)
|
// Count posts with new engagement (before marking as seen)
|
||||||
for (const p of mine) {
|
if (currentTab !== 'myposts') {
|
||||||
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
|
let newEngagement = 0;
|
||||||
const totalComments = p.commentCount || 0;
|
for (const p of mine) {
|
||||||
if (totalReacts > 0 || totalComments > 0) {
|
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
|
||||||
invoke('mark_post_seen', { postId: p.id, reactCount: totalReacts, commentCount: totalComments }).catch(() => {});
|
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) {
|
} catch (e) {
|
||||||
|
|
@ -862,9 +897,13 @@ async function loadMessages(force) {
|
||||||
const msgsHtml = item.querySelector('.chat-window').innerHTML;
|
const msgsHtml = item.querySelector('.chat-window').innerHTML;
|
||||||
const partnerName = item.querySelector('.conv-name').textContent;
|
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
|
// Collect post IDs for receipt/seen tracking
|
||||||
const threadPostIds = thread.posts.filter(p => {
|
const threadPostIds = threadPosts.filter(p => {
|
||||||
const enc = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
|
const enc = p.visibility === 'encrypted-for-me' || p.intentKind === 'direct' || (p.recipients && p.recipients.length > 0);
|
||||||
return enc;
|
return enc;
|
||||||
}).map(p => p.id);
|
}).map(p => p.id);
|
||||||
|
|
||||||
|
|
@ -888,7 +927,7 @@ async function loadMessages(force) {
|
||||||
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
||||||
|
|
||||||
// Mark incoming encrypted messages as "seen"
|
// Mark incoming encrypted messages as "seen"
|
||||||
for (const p of thread.posts) {
|
for (const p of threadPosts) {
|
||||||
if (!p.isMe && threadPostIds.includes(p.id)) {
|
if (!p.isMe && threadPostIds.includes(p.id)) {
|
||||||
invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {});
|
invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -921,6 +960,7 @@ async function loadMessages(force) {
|
||||||
$('#popover-reply-btn').disabled = true;
|
$('#popover-reply-btn').disabled = true;
|
||||||
try {
|
try {
|
||||||
await invoke('create_post', { content, visibility: 'direct', recipientHex: partnerId });
|
await invoke('create_post', { content, visibility: 'direct', recipientHex: partnerId });
|
||||||
|
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
||||||
input.value = '';
|
input.value = '';
|
||||||
toast('Reply sent!');
|
toast('Reply sent!');
|
||||||
closePopover();
|
closePopover();
|
||||||
|
|
@ -934,6 +974,10 @@ async function loadMessages(force) {
|
||||||
};
|
};
|
||||||
$('#popover-reply-btn').addEventListener('click', sendReply);
|
$('#popover-reply-btn').addEventListener('click', sendReply);
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) 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);
|
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) {
|
} catch (e) {
|
||||||
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
|
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))
|
? others.filter(f => !online.includes(f))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
updateTabBadge('people', online.length);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
if (online.length > 0) {
|
if (online.length > 0) {
|
||||||
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
||||||
|
|
@ -2311,6 +2364,8 @@ async function doSendDM() {
|
||||||
visibility: 'direct',
|
visibility: 'direct',
|
||||||
recipientHex: recipient,
|
recipientHex: recipient,
|
||||||
});
|
});
|
||||||
|
// Mark conversation as read so we don't re-notify ourselves
|
||||||
|
invoke('mark_conversation_read', { partnerId: recipient }).catch(() => {});
|
||||||
dmContent.value = '';
|
dmContent.value = '';
|
||||||
toast('Message sent!');
|
toast('Message sent!');
|
||||||
loadMessages(true);
|
loadMessages(true);
|
||||||
|
|
@ -2684,10 +2739,16 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
newView.classList.add('active');
|
newView.classList.add('active');
|
||||||
currentTab = target;
|
currentTab = target;
|
||||||
if (target === 'feed') {
|
if (target === 'feed') {
|
||||||
|
_lastFeedViewMs = Date.now();
|
||||||
|
updateTabBadge('feed', 0);
|
||||||
if (!feedList.children.length) feedList.innerHTML = renderLoading();
|
if (!feedList.children.length) feedList.innerHTML = renderLoading();
|
||||||
loadFeed(true);
|
loadFeed(true);
|
||||||
}
|
}
|
||||||
if (target === 'myposts') { loadMyPosts(true); loadCircles(); }
|
if (target === 'myposts') {
|
||||||
|
_lastMyPostsViewMs = Date.now();
|
||||||
|
updateTabBadge('myposts', 0);
|
||||||
|
loadMyPosts(true); loadCircles();
|
||||||
|
}
|
||||||
if (target === 'people') {
|
if (target === 'people') {
|
||||||
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
||||||
loadFollows(); loadAudience();
|
loadFollows(); loadAudience();
|
||||||
|
|
@ -2927,12 +2988,21 @@ async function init() {
|
||||||
setupName.focus();
|
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(() => {
|
setInterval(() => {
|
||||||
if (currentTab === 'feed') loadFeed();
|
if (currentTab === 'feed') loadFeed();
|
||||||
if (currentTab === 'myposts') loadMyPosts();
|
if (currentTab === 'myposts') loadMyPosts();
|
||||||
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
||||||
loadStats();
|
updateNetworkIndicator();
|
||||||
|
// Update badges for non-active tabs
|
||||||
|
if (currentTab !== 'feed') loadFeed();
|
||||||
|
if (currentTab !== 'myposts') loadMyPosts();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
// Tiered DM polling: frequency based on recency of last message
|
// Tiered DM polling: frequency based on recency of last message
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>ItsGoin</h1>
|
<div id="header-row">
|
||||||
<div id="stats-bar">
|
<h1>ItsGoin</h1>
|
||||||
<span id="stat-posts">0 posts</span> |
|
<div id="net-indicator">
|
||||||
<span id="stat-peers">0 peers</span> |
|
<span id="net-dot"></span>
|
||||||
<span id="stat-follows">0 following</span>
|
<span id="net-labels"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,16 @@
|
||||||
select option { color: #000 !important; }
|
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; }
|
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 { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||||
header h1 { font-size: 1.4rem; color: #7fdbca; }
|
#header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
#stats-bar { font-size: 0.8rem; color: #bbc; margin-top: 0.25rem; }
|
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 */
|
/* 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; }
|
.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>
|
<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;">
|
<div class="card" style="margin-top: 1rem;">
|
||||||
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
|
<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.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.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>
|
<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">
|
||||||
<div class="changelog-date">v0.3.6 — March 20, 2026</div>
|
<div class="changelog-date">v0.3.6 — March 20, 2026</div>
|
||||||
<ul>
|
<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>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>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>
|
<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