v0.3.6: Active CDN replication, device roles, budgets, tombstones, engagement fix, DOS hardening
Active CDN replication: - All devices proactively replicate recent posts (<72h, <2 replicas) to peers - Target priority: desktops (300) > anchors (200) > phones (100) + cache_pressure - ReplicationRequest/Response (0xE1/0xE2) wire messages - 10-min cycle, 2-min initial delay, cap 20 posts per request - Graceful with small networks (1 peer = 1 replica, 0 peers = silent skip) Device roles & budgets: - Intermittent (phone), Available (desktop), Persistent (anchor) - Advertised in InitialExchange, stored per-peer - Replication budget: phones 100MB/hr, desktops/anchors 200MB/hr - Delivery budget: phones 1GB/hr, desktops 2GB/hr, anchors 1GB/hr - Hourly auto-reset, enforcement on blob serving Cache management: - 1GB default cache limit, configurable in settings UI - Eviction cycle activated (was implemented but never started) - Share-link priority boost (+100 for 3+ downstream) - Cache pressure score (0-255) for replication targeting Engagement distribution fix: - BlobHeader JSON rebuilt after BlobHeaderDiff ops - Previously reactions/comments stored in tables but header stayed stale Tombstone system: - deleted_at column on reactions and comments - Tombstones propagate through pull sync (additive merge respects timestamps) - UI queries filter WHERE deleted_at IS NULL Persistent notifications: - seen_engagement and seen_messages tables replace in-memory Sets - Only notify on genuinely unseen content, survives restarts DOS hardening: - BlobHeaderDiff fan-out: single batched task, max 10 concurrent via JoinSet - Blob prefetch: cap 20 per cycle, newest first - PostDownstreamRegister: cap 50 per sync - Delivery budget enforcement on BlobRequest handler - Pull preference: non-anchors first to preserve anchor delivery budget Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b7f2d369fa
commit
a7e632de88
16 changed files with 1254 additions and 158 deletions
125
frontend/app.js
125
frontend/app.js
|
|
@ -358,10 +358,6 @@ function toast(msg) {
|
|||
}
|
||||
|
||||
// --- Notifications (Tauri plugin) ---
|
||||
let _notifiedMessages = new Set();
|
||||
let _notifiedReacts = new Set();
|
||||
let _notifiedComments = new Set();
|
||||
let _notifiedPosts = new Set();
|
||||
let _notifReady = false;
|
||||
async function maybeNotify(title, body, tag) {
|
||||
try {
|
||||
|
|
@ -635,40 +631,24 @@ async function loadFeed(force) {
|
|||
const oldFp = _feedFingerprint;
|
||||
_feedFingerprint = fp;
|
||||
|
||||
// Notify on new posts and engagement (skip first load)
|
||||
// Notify on new posts and engagement (DB-backed seen tracking)
|
||||
if (_notifReady && oldFp) {
|
||||
try {
|
||||
const notifPosts = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
|
||||
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
|
||||
for (const p of posts) {
|
||||
// New post notifications
|
||||
if (!p.isMe && notifPosts !== 'off' && !_notifiedPosts.has(p.id)) {
|
||||
_notifiedPosts.add(p.id);
|
||||
if (_notifiedPosts.size > posts.length) { // skip initial bulk
|
||||
maybeNotify(`New post from ${p.authorName || p.author.slice(0,8)}`, (p.content || '').slice(0, 80), `post-${p.id}`);
|
||||
}
|
||||
if (!p.isMe) continue;
|
||||
if (notifReacts === 'off') continue;
|
||||
// Get DB-persisted seen counts
|
||||
const seen = await invoke('get_seen_engagement', { postId: p.id }).catch(() => ({ seenReactCount: 0, seenCommentCount: 0 }));
|
||||
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
|
||||
const totalComments = p.commentCount || 0;
|
||||
if (totalReacts > seen.seenReactCount) {
|
||||
const newReacts = totalReacts - seen.seenReactCount;
|
||||
maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`);
|
||||
}
|
||||
// Reaction notifications on our posts
|
||||
if (p.isMe && notifReacts !== 'off' && p.reactionCounts) {
|
||||
for (const r of p.reactionCounts) {
|
||||
const key = `${p.id}-${r.emoji}-${r.count}`;
|
||||
if (!_notifiedReacts.has(key)) {
|
||||
_notifiedReacts.add(key);
|
||||
if (_notifiedReacts.size > 1) {
|
||||
maybeNotify(`${r.emoji} on your post`, `${r.count} ${r.emoji} reactions`, `react-${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Comment notifications on our posts
|
||||
if (p.isMe && notifReacts !== 'off' && p.commentCount > 0) {
|
||||
const key = `${p.id}-comments-${p.commentCount}`;
|
||||
if (!_notifiedComments.has(key)) {
|
||||
_notifiedComments.add(key);
|
||||
if (_notifiedComments.size > 1) {
|
||||
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
|
||||
}
|
||||
}
|
||||
if (totalComments > seen.seenCommentCount) {
|
||||
const newComments = totalComments - seen.seenCommentCount;
|
||||
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
|
@ -730,6 +710,14 @@ 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(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
myPostsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
|
|
@ -802,19 +790,18 @@ async function loadMessages(force) {
|
|||
if (!force && fp === _messagesFingerprint) return;
|
||||
_messagesFingerprint = fp;
|
||||
|
||||
// Notify on new incoming messages
|
||||
// Notify on new incoming messages (DB-backed seen tracking)
|
||||
try {
|
||||
const notifMsg = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
||||
if (notifMsg !== 'off') {
|
||||
if (_notifReady && notifMsg !== 'off') {
|
||||
for (const [partnerId, thread] of sortedThreads) {
|
||||
const lastReadMs = await invoke('get_last_read_message', { partnerIdHex: partnerId }).catch(() => 0);
|
||||
for (const p of thread.posts) {
|
||||
if (p.isMe || _notifiedMessages.has(p.id)) continue;
|
||||
_notifiedMessages.add(p.id);
|
||||
if (_notifiedMessages.size > 1) { // skip first load
|
||||
const name = thread.partnerName || partnerId.slice(0, 8);
|
||||
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
||||
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
||||
}
|
||||
if (p.isMe) continue;
|
||||
if (p.timestampMs <= lastReadMs) continue;
|
||||
const name = thread.partnerName || partnerId.slice(0, 8);
|
||||
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
||||
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -897,6 +884,9 @@ async function loadMessages(force) {
|
|||
const input = $('#popover-reply-input');
|
||||
if (input) setTimeout(() => input.focus(), 100);
|
||||
|
||||
// Mark conversation as read (DB-backed)
|
||||
invoke('mark_conversation_read', { partnerId }).catch(() => {});
|
||||
|
||||
// Mark incoming encrypted messages as "seen"
|
||||
for (const p of thread.posts) {
|
||||
if (!p.isMe && threadPostIds.includes(p.id)) {
|
||||
|
|
@ -2364,7 +2354,7 @@ async function doSyncAll() {
|
|||
if (currentTab === 'myposts') loadMyPosts(true);
|
||||
if (currentTab === 'people') { loadFollows(); }
|
||||
if (currentTab === 'messages') loadMessages(true);
|
||||
if (currentTab === 'settings') { loadRedundancy(); loadPublicVisible(); if (diagnosticsInterval) loadAllDiagnostics(); }
|
||||
if (currentTab === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheStats(); if (diagnosticsInterval) loadAllDiagnostics(); }
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast('Sync error: ' + e);
|
||||
|
|
@ -2422,6 +2412,53 @@ async function loadPublicVisible() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Cache storage settings ---
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
async function loadCacheStats() {
|
||||
try {
|
||||
const stats = await invoke('get_cache_stats');
|
||||
const display = $('#cache-stats-display');
|
||||
const maxLabel = stats.maxBytes === 0 ? 'Unlimited' : formatBytes(stats.maxBytes);
|
||||
const pct = stats.maxBytes > 0 ? ` (${(stats.usedBytes / stats.maxBytes * 100).toFixed(1)}%)` : '';
|
||||
display.textContent = `${formatBytes(stats.usedBytes)} used of ${maxLabel}${pct} — ${stats.blobCount} blobs`;
|
||||
} catch (e) {
|
||||
console.error('loadCacheStats:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCacheSizeSetting() {
|
||||
try {
|
||||
const val = await invoke('get_setting', { key: 'cache_size_bytes' });
|
||||
if (val) {
|
||||
const sel = $('#cache-size-select');
|
||||
// Match the option value
|
||||
for (const opt of sel.options) {
|
||||
if (opt.value === val) { sel.value = val; break; }
|
||||
}
|
||||
}
|
||||
await loadCacheStats();
|
||||
} catch (e) {
|
||||
console.error('loadCacheSizeSetting:', e);
|
||||
}
|
||||
}
|
||||
|
||||
$('#cache-size-select').addEventListener('change', async () => {
|
||||
const value = $('#cache-size-select').value;
|
||||
try {
|
||||
await invoke('set_setting', { key: 'cache_size_bytes', value });
|
||||
toast('Cache size updated — takes effect on next eviction cycle');
|
||||
await loadCacheStats();
|
||||
} catch (e) {
|
||||
toast('Error saving cache size: ' + e);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Circle profiles ---
|
||||
async function loadCircleProfiles() {
|
||||
const container = $('#circle-profiles-list');
|
||||
|
|
@ -2659,7 +2696,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|||
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
||||
loadMessages(true); loadDmRecipientOptions();
|
||||
}
|
||||
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); }
|
||||
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue