Feed pagination, duplicate identity detection, pkarr leak fix, Android SAF

Feed pagination:
- Cursor-based pagination: get_feed_page/get_all_posts_page (20 posts/page)
- Batched engagement queries (3 bulk SQL queries instead of 4 per post)
- IntersectionObserver for infinite scroll (sentinel at midpoint)
- Viewport-based media loading (blobs only load when post enters view)
- Pre-fetch next page immediately after current page renders

Duplicate identity detection:
- Anchor detects when a NodeId is already mesh-connected during initial
  exchange and sets duplicate_active flag in response
- Client skips sync tasks when duplicate detected
- Frontend shows red warning banner

Privacy:
- Fixed pkarr leak: clear_address_lookup() removes default dns.iroh.link
  publishing. Only mDNS (local network) discovery enabled.

Android:
- SAF integration via tauri-plugin-android-fs: exports open native "Save As"
  dialog so users can save to Downloads/Drive/etc.
- Download/export paths use app data dir on Android (writable)
- File picker gated behind desktop cfg (blocking_pick not on Android)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-18 15:35:23 -04:00
parent 5e7eed9638
commit 288b53ffb1
12 changed files with 910 additions and 120 deletions

View file

@ -713,130 +713,223 @@ async function loadStats() {
}
}
// --- Feed pagination state ---
let _feedCursor = null; // oldest_ms for next page
let _feedHasMore = true;
let _feedPrefetch = null; // pre-fetched next page (Promise)
let _feedLoading = false;
let _feedMediaObserver = null; // IntersectionObserver for viewport media
let _feedScrollObserver = null; // IntersectionObserver for infinite scroll
let _feedPostIds = new Set(); // track loaded post IDs to avoid duplicates
function filterFeedPosts(posts) {
return posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
}
async function loadFeed(force) {
if (_feedLoading) return;
_feedLoading = true;
try {
const allPosts = await invoke('get_feed');
const posts = allPosts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
// Fingerprint: post IDs + reaction counts + comment counts
// First page or refresh: load newest 20
const result = await invoke('get_feed_page', { limit: 20 });
const posts = filterFeedPosts(result.posts);
// Fingerprint first page for refresh detection
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _feedFingerprint) return;
if (!force && fp === _feedFingerprint) { _feedLoading = false; return; }
const oldFp = _feedFingerprint;
_feedFingerprint = fp;
// Ticker for new posts from others
// Ticker for new posts
if (_notifReady && oldFp) {
const oldIds = new Set(oldFp.split('|').map(s => s.split(':')[0]));
for (const p of posts) {
if (!p.isMe && !oldIds.has(p.id)) {
const name = p.authorName || p.author.substring(0, 8);
showTicker(`New post from ${name}`);
break; // one ticker per cycle
showTicker(`New post from ${p.authorName || p.author.substring(0, 8)}`);
break;
}
}
}
// Notify on engagement (DB-backed seen tracking)
if (_notifReady && oldFp) {
try {
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
for (const p of posts) {
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}`);
showTicker(`New reaction on your post`);
}
if (totalComments > seen.seenCommentCount) {
const newComments = totalComments - seen.seenCommentCount;
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
showTicker(`New comment on your post`);
}
}
} catch (_) {}
}
// Skip full re-render if any video/audio is actively playing (prevents echo/restart)
// Skip re-render if media playing
const mediaPlaying = [...feedList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) {
// Don't destroy the DOM while media is playing — re-render on next cycle when stopped
return;
}
if (mediaPlaying) { _feedLoading = false; return; }
// Revoke old object URLs to prevent memory leaks
// Revoke old blob URLs
feedList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
// Preserve expanded comment threads
const expandedComments = new Set();
feedList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => {
const postEl = el.closest('.post');
if (postEl) expandedComments.add(postEl.dataset.postId);
});
// Reset pagination state
_feedCursor = result.oldestMs || null;
_feedHasMore = result.hasMore;
_feedPostIds = new Set(posts.map(p => p.id));
if (posts.length === 0) {
// Don't lock in empty fingerprint — let next refresh re-render when posts arrive
_feedFingerprint = null;
feedList.innerHTML = renderEmptyState(
'Your feed is empty',
'Follow peers on the People tab to see their posts here.'
);
feedList.innerHTML = renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
} else {
feedList.innerHTML = posts.map(renderPost).join('');
loadPostMedia(feedList);
// Restore expanded comment threads
for (const postId of expandedComments) {
const thread = feedList.querySelector(`#comments-${postId}`);
if (thread) {
thread.classList.remove('hidden');
loadCommentThread(postId, thread);
}
// Add scroll sentinel at midpoint
if (_feedHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'feed-scroll-sentinel';
const children = feedList.children;
const mid = Math.min(Math.floor(children.length / 2), children.length - 1);
children[mid].after(sentinel);
setupFeedScrollObserver();
}
setupFeedMediaObserver();
}
// Pre-fetch next page immediately
if (_feedHasMore && _feedCursor) {
_feedPrefetch = invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
feedList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
} finally {
_feedLoading = false;
}
}
async function loadMyPosts(force) {
async function appendFeedPage() {
if (_feedLoading || !_feedHasMore) return;
_feedLoading = true;
try {
const posts = await invoke('get_all_posts');
const mine = posts.filter(p => p.isMe && p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && p.recipients && p.recipients.length > 0));
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _myPostsFingerprint) return;
_myPostsFingerprint = fp;
// Skip re-render if media is playing
const mediaPlaying = [...myPostsList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) return;
// Revoke old blob URLs
myPostsList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
const expandedComments = new Set();
myPostsList.querySelectorAll('.comment-thread:not(.hidden)').forEach(el => {
const postEl = el.closest('.post');
if (postEl) expandedComments.add(postEl.dataset.postId);
});
if (mine.length === 0) {
myPostsList.innerHTML = renderEmptyState(
'No posts yet',
'Write your first post above!'
);
} else {
myPostsList.innerHTML = mine.map(renderPost).join('');
loadPostMedia(myPostsList);
for (const postId of expandedComments) {
const thread = myPostsList.querySelector(`#comments-${postId}`);
if (thread) {
thread.classList.remove('hidden');
loadCommentThread(postId, thread);
// Use pre-fetched data if available, otherwise fetch now
let result;
if (_feedPrefetch) {
result = await _feedPrefetch;
_feedPrefetch = null;
}
if (!result) {
result = await invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 });
}
const posts = filterFeedPosts(result.posts).filter(p => !_feedPostIds.has(p.id));
if (posts.length === 0) { _feedHasMore = false; _feedLoading = false; return; }
_feedCursor = result.oldestMs || null;
_feedHasMore = result.hasMore;
posts.forEach(p => _feedPostIds.add(p.id));
// Remove old sentinel
const oldSentinel = document.getElementById('feed-scroll-sentinel');
if (oldSentinel) oldSentinel.remove();
// Append posts
const fragment = document.createDocumentFragment();
const temp = document.createElement('div');
temp.innerHTML = posts.map(renderPost).join('');
while (temp.firstChild) fragment.appendChild(temp.firstChild);
// Insert new sentinel at midpoint of new posts
if (_feedHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'feed-scroll-sentinel';
const newNodes = [...fragment.children];
const mid = Math.min(Math.floor(newNodes.length / 2), newNodes.length - 1);
if (newNodes[mid]) newNodes[mid].after(sentinel);
}
feedList.appendChild(fragment);
setupFeedScrollObserver();
// Media observer auto-picks up new posts
// Pre-fetch next page
if (_feedHasMore && _feedCursor) {
_feedPrefetch = invoke('get_feed_page', { beforeMs: _feedCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
console.error('appendFeedPage:', e);
} finally {
_feedLoading = false;
}
}
function setupFeedScrollObserver() {
if (_feedScrollObserver) _feedScrollObserver.disconnect();
const sentinel = document.getElementById('feed-scroll-sentinel');
if (!sentinel) return;
_feedScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) appendFeedPage();
}, { rootMargin: '200px' });
_feedScrollObserver.observe(sentinel);
}
function setupFeedMediaObserver() {
if (_feedMediaObserver) _feedMediaObserver.disconnect();
_feedMediaObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const post = entry.target;
if (!post.dataset.mediaLoaded) {
post.dataset.mediaLoaded = '1';
loadPostMedia(post);
}
}
}
// Mark all visible own posts' engagement as seen (DB-backed) when viewing tab
}, { rootMargin: '400px' }); // start loading 400px before viewport
feedList.querySelectorAll('.post').forEach(post => _feedMediaObserver.observe(post));
// Also observe new posts added later via MutationObserver
const mutObs = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList && node.classList.contains('post')) {
_feedMediaObserver.observe(node);
}
}
}
});
mutObs.observe(feedList, { childList: true });
}
// --- My Posts pagination state ---
let _myPostsCursor = null;
let _myPostsHasMore = true;
let _myPostsPrefetch = null;
let _myPostsLoading = false;
let _myPostsMediaObserver = null;
let _myPostsScrollObserver = null;
let _myPostsIds = new Set();
async function loadMyPosts(force) {
if (_myPostsLoading) return;
_myPostsLoading = true;
try {
const result = await invoke('get_all_posts_page', { limit: 20 });
const mine = result.posts.filter(p => p.isMe && p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && p.recipients && p.recipients.length > 0));
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _myPostsFingerprint) { _myPostsLoading = false; return; }
_myPostsFingerprint = fp;
const mediaPlaying = [...myPostsList.querySelectorAll('video, audio')].some(el => !el.paused);
if (mediaPlaying) { _myPostsLoading = false; return; }
myPostsList.querySelectorAll('video[src^="blob:"], audio[src^="blob:"], img[src^="blob:"]').forEach(el => {
if (el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
_myPostsCursor = result.oldestMs || null;
_myPostsHasMore = result.hasMore;
_myPostsIds = new Set(mine.map(p => p.id));
if (mine.length === 0) {
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
} else {
myPostsList.innerHTML = mine.map(renderPost).join('');
if (_myPostsHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'myposts-scroll-sentinel';
const children = myPostsList.children;
const mid = Math.min(Math.floor(children.length / 2), children.length - 1);
children[mid].after(sentinel);
setupMyPostsScrollObserver();
}
setupMyPostsMediaObserver();
}
// Mark visible own posts as seen
if (currentTab === 'myposts') {
for (const p of mine) {
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
@ -846,11 +939,88 @@ async function loadMyPosts(force) {
}
}
}
if (_myPostsHasMore && _myPostsCursor) {
_myPostsPrefetch = invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 }).catch(() => null);
}
} catch (e) {
myPostsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
} finally {
_myPostsLoading = false;
}
}
async function appendMyPostsPage() {
if (_myPostsLoading || !_myPostsHasMore) return;
_myPostsLoading = true;
try {
let result = _myPostsPrefetch ? await _myPostsPrefetch : null;
_myPostsPrefetch = null;
if (!result) result = await invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 });
const mine = result.posts.filter(p => p.isMe && p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && p.recipients && p.recipients.length > 0))
.filter(p => !_myPostsIds.has(p.id));
if (mine.length === 0) { _myPostsHasMore = false; _myPostsLoading = false; return; }
_myPostsCursor = result.oldestMs || null;
_myPostsHasMore = result.hasMore;
mine.forEach(p => _myPostsIds.add(p.id));
const oldSentinel = document.getElementById('myposts-scroll-sentinel');
if (oldSentinel) oldSentinel.remove();
const fragment = document.createDocumentFragment();
const temp = document.createElement('div');
temp.innerHTML = mine.map(renderPost).join('');
while (temp.firstChild) fragment.appendChild(temp.firstChild);
if (_myPostsHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'myposts-scroll-sentinel';
const newNodes = [...fragment.children];
const mid = Math.min(Math.floor(newNodes.length / 2), newNodes.length - 1);
if (newNodes[mid]) newNodes[mid].after(sentinel);
}
myPostsList.appendChild(fragment);
setupMyPostsScrollObserver();
if (_myPostsHasMore && _myPostsCursor) {
_myPostsPrefetch = invoke('get_all_posts_page', { beforeMs: _myPostsCursor, limit: 20 }).catch(() => null);
}
} catch (_) {} finally { _myPostsLoading = false; }
}
function setupMyPostsScrollObserver() {
if (_myPostsScrollObserver) _myPostsScrollObserver.disconnect();
const sentinel = document.getElementById('myposts-scroll-sentinel');
if (!sentinel) return;
_myPostsScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) appendMyPostsPage();
}, { rootMargin: '200px' });
_myPostsScrollObserver.observe(sentinel);
}
function setupMyPostsMediaObserver() {
if (_myPostsMediaObserver) _myPostsMediaObserver.disconnect();
_myPostsMediaObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !entry.target.dataset.mediaLoaded) {
entry.target.dataset.mediaLoaded = '1';
loadPostMedia(entry.target);
}
}
}, { rootMargin: '400px' });
myPostsList.querySelectorAll('.post').forEach(post => _myPostsMediaObserver.observe(post));
const mutObs = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList && node.classList.contains('post')) _myPostsMediaObserver.observe(node);
}
}
});
mutObs.observe(myPostsList, { childList: true });
}
async function loadMessages(force) {
try {
const [posts, follows] = await Promise.all([
@ -3401,6 +3571,18 @@ $('#export-btn').addEventListener('click', () => {
try {
const result = await invoke('export_data', { scope, outputDir });
status.textContent = result;
// On mobile: extract file path from result and offer to save via SAF
const pathMatch = result.match(/:\s*(.+\.zip)/);
if (pathMatch) {
try {
status.textContent = 'Saving to device...';
const shareResult = await invoke('share_file', { filePath: pathMatch[1], mimeType: 'application/zip' });
status.textContent = shareResult === 'Cancelled' ? 'Export saved internally. ' + result : shareResult;
} catch (shareErr) {
// share_file not available (desktop) or failed — that's ok, file is in app dir
status.textContent = result;
}
}
toast('Export complete!');
} catch (e) {
status.textContent = 'Error: ' + e;
@ -3645,6 +3827,7 @@ async function init() {
if (feedTab) feedTab.classList.add('active');
document.getElementById('view-feed').classList.add('active');
currentTab = 'feed';
loadFeed(true);
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
});
@ -3721,12 +3904,27 @@ async function init() {
// Auto-refresh every 10 seconds — only the active tab
const _initTime = Date.now();
setInterval(() => {
let _duplicateWarningShown = false;
setInterval(async () => {
const startup = Date.now() - _initTime < 30000; // force during first 30s
if (currentTab === 'feed') loadFeed(startup);
if (currentTab === 'myposts') loadMyPosts(startup);
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
updateNetworkIndicator();
// Check for duplicate identity (set by anchor during bootstrap)
if (!_duplicateWarningShown) {
try {
const info = await invoke('get_node_info');
if (info && info.duplicateDetected) {
_duplicateWarningShown = true;
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#c0392b;color:#fff;padding:0.5rem;text-align:center;font-size:0.8rem;z-index:999;';
banner.textContent = 'This identity is active on another device. Sync paused to prevent data conflicts.';
document.body.prepend(banner);
toast('Duplicate identity detected — sync paused');
}
} catch (_) {}
}
}, 10000);
// Badge updates for non-active tabs — every 30 seconds (single IPC call)