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:
parent
5e7eed9638
commit
288b53ffb1
12 changed files with 910 additions and 120 deletions
378
frontend/app.js
378
frontend/app.js
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue