People tab rewrite: recency sort, profile-post Discover, bio modal,

per-author feed filter, ignore primitive

The old People tab was built on network-layer presence (`is_online`,
`last_seen` from the mesh), which was lost when v0.6.1 anonymized the
network id from the posting id. Every named follow is authored under a
posting id that doesn't appear in the connection-layer tables; the
"Online" section listed nobody useful and Discover depended on the
same broken signal.

Replaced with signals derived from signed content:
- Following is sorted by most-recent-post timestamp (the real meaning
  of "activity" in a post-anonymization world).
- Discover lists named peers we've received signed profile posts from
  (via Phase 2d), filtered by follows / ignores / self.
- Click-a-name surfaces a bio modal with View Posts / Follow / Message
  / Ignore actions.
- Author-scoped feed filter (`View Posts` on any person) renders a
  "Showing posts from X" banner with a Clear button.
- Ignore is a new local-only primitive; ignored peers' posts and
  profiles are excluded everywhere and the ignored list is editable
  in Settings.

Core changes:

- New `ignored_peers(node_id, ignored_at)` table + storage helpers
  (`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`,
  `is_ignored_peer`). Schema created fresh; no migration since the
  table is purely additive and empty on prior installs.
- All 6 feed-query sites now also exclude `author IN ignored_peers`.
- New `Storage::last_activity_for_authors(&[NodeId])` — one batched
  query returning max post timestamp per author, excluding non-feed
  intents (Control / Profile / Announcement / GroupKeyDistribute).
- New `Storage::list_discoverable_profiles(&self_id)` — named profile
  rows where node_id is not self, not in follows, not in ignored, and
  `public_visible = 1`. Sorted by profile `updated_at` DESC.
- New `Storage::delete_setting(key)` — missing counterpart to
  set/get.
- Node wrappers: `last_activity_for_follows`, `ignore_peer`
  (also drops any follow + social route for the ignored peer),
  `unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`.
- `list_follows` Tauri command now sources `last_activity_ms` from
  the posts-driven batched query rather than the network peer record.

Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`,
`list_ignored_peers`.

Frontend:
- Following list: see-new-activity button pattern (staged data +
  explicit user click to rearrange, so the list doesn't reorder under
  a tap mid-scroll). Periodic people-tab polling stages + lights up
  the button; clicking it re-renders.
- Discover: rewrites the old peer-table-based list to a profile-post
  feed. Each card shows name + bio + profile-update age, plus Follow
  / Posts / Ignore actions.
- Bio modal: reuses the existing generic popover. Loads display name
  + bio via `resolve_display`, shows follow state, offers View Posts /
  Follow-or-Unfollow / Message / Ignore-or-Unignore.
- Author filter: banner renders at the top of the feed when active;
  clear button restores full feed. Filter state is a single `authorFilterNodeId`
  field consumed by `filterFeedPosts`.
- Settings → Ignored section lists ignored peers with unignore buttons.

124 / 124 core tests pass.
This commit is contained in:
Scott Reimers 2026-04-23 12:15:51 -04:00
parent e74bd4e6c6
commit 2ce668aa58
5 changed files with 595 additions and 168 deletions

View file

@ -727,7 +727,12 @@ 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))));
let filtered = posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
// Per-author filter (driven by "View Posts" button or bio modal).
if (authorFilterNodeId) {
filtered = filtered.filter(p => p.author === authorFilterNodeId);
}
return filtered;
}
async function loadFeed(force) {
@ -769,11 +774,29 @@ async function loadFeed(force) {
_feedHasMore = result.hasMore;
_feedPostIds = new Set(posts.map(p => p.id));
const filterBanner = authorFilterNodeId
? `<div id="author-filter-banner" style="padding:0.5rem 0.75rem;background:#1a1a2e;border-left:3px solid #7fdbca;margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">
<span>Showing posts from <strong>${escapeHtml(authorFilterName || authorFilterNodeId.slice(0, 12))}</strong></span>
<button id="clear-author-filter" class="btn btn-ghost btn-sm">Clear</button>
</div>`
: '';
if (posts.length === 0) {
_feedFingerprint = null;
feedList.innerHTML = renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
const empty = authorFilterNodeId
? renderEmptyState('No posts from this person', 'They may not have published any visible posts yet.')
: renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
feedList.innerHTML = filterBanner + empty;
if (authorFilterNodeId) {
const clearBtn = document.getElementById('clear-author-filter');
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
}
} else {
feedList.innerHTML = posts.map(renderPost).join('');
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
if (authorFilterNodeId) {
const clearBtn = document.getElementById('clear-author-filter');
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
}
// Add scroll sentinel at midpoint
if (_feedHasMore) {
const sentinel = document.createElement('div');
@ -1420,186 +1443,282 @@ async function loadPeerBios(container) {
}
}
// Highest lastActivityMs shown at last render. When new activity arrives
// that beats this, we show a "See new activity" button instead of
// resorting under the user.
let _followsDisplayedMaxActivity = 0;
let _followsLatestMaxActivity = 0;
let _followsPendingData = null; // cached follows array waiting for user's click
async function loadFollows() {
try {
// v0.6.2: audience removed. No more audience/mutual badges or request flow.
// v0.6.2 People-tab rewrite:
// - Online indicator is gone (network/posting id split hid presence).
// - Sort by last post timestamp (DESC). Authors we've never seen
// post go to the bottom.
// - Don't resort under the user mid-interaction: when new activity
// arrives, show a "See new activity" button instead.
const follows = await invoke('list_follows');
// Filter out self before rendering
const others = follows.filter(f => f.nodeId !== myNodeId);
if (others.length === 0) {
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
} else {
const now = Date.now();
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
const renderFollowCard = (f) => {
const icon = generateIdenticon(f.nodeId, 18);
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
const isSelf = f.nodeId === myNodeId;
const newMax = others.reduce((m, f) => Math.max(m, f.lastActivityMs || 0), 0);
let lastSeenHtml = '';
let actions = '';
if (isSelf) {
actions = '<span class="self-tag">(you)</span>';
} else {
if (!f.isOnline && f.lastActivityMs > 0) {
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
}
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
}
return `<div class="peer-card" data-node-id="${f.nodeId}">
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a></div>
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
<div class="peer-card-bio"></div>
<div class="peer-card-actions">${actions}</div>
</div>`;
};
// If isOnline field isn't available (old build), show all as online
const hasOnlineField = others.some(f => f.isOnline !== undefined);
const online = hasOnlineField
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
: others;
const offline = hasOnlineField
? 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>`;
html += online.map(renderFollowCard).join('');
}
if (offline.length > 0) {
html += `<div class="follows-section-header follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
}
followsList.innerHTML = html;
// Open offline follows in lightbox
if (offline.length > 0) {
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const existing = document.querySelector('.offline-lightbox');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'offline-lightbox';
overlay.innerHTML = `
<div class="offline-lightbox-content">
<div class="offline-lightbox-header">
<h3>Following: Offline (${offline.length})</h3>
<button class="offline-lightbox-close">x</button>
</div>
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Wire up buttons inside the lightbox
attachFollowHandlers(overlay);
});
});
}
// Attach unfollow handlers
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
toast('Unfollowed');
loadFollows();
loadStats();
loadFeed(true);
} catch (e) {
toast('Error: ' + e);
} finally {
btn.disabled = false;
}
});
});
// Attach per-peer sync handlers
followsList.querySelectorAll('.sync-peer-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Syncing...';
try {
await invoke('sync_from_peer', { nodeIdHex: btn.dataset.nodeId });
toast('Sync complete!');
loadFeed(true);
loadMyPosts(true);
} catch (e) {
toast('Error: ' + e);
} finally {
btn.disabled = false;
btn.textContent = 'Sync';
}
});
});
// Lazy-load bios
loadPeerBios(followsList);
if (_followsDisplayedMaxActivity === 0) {
_followsDisplayedMaxActivity = newMax;
}
_followsLatestMaxActivity = newMax;
// If the user has already rendered a list and new activity has come
// in since, stash the new data and reveal the refresh button.
if (newMax > _followsDisplayedMaxActivity && followsList.childElementCount > 0) {
_followsPendingData = others;
const btn = document.getElementById('follows-refresh-btn');
if (btn) {
const delta = others.filter(f => (f.lastActivityMs || 0) > _followsDisplayedMaxActivity).length;
btn.textContent = `See ${delta} new update${delta === 1 ? '' : 's'}`;
btn.classList.remove('hidden');
}
return;
}
renderFollowsList(others);
_followsDisplayedMaxActivity = newMax;
const btn = document.getElementById('follows-refresh-btn');
if (btn) btn.classList.add('hidden');
} catch (e) {
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
}
}
function renderFollowsList(others) {
if (others.length === 0) {
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Discover named people below or connect manually.')}</div>`;
updateTabBadge('people', 0);
return;
}
// Sort by last activity DESC; never-posted authors go to the bottom.
const sorted = [...others].sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
updateTabBadge('people', 0);
const renderFollowCard = (f) => {
const icon = generateIdenticon(f.nodeId, 18);
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
const activity = (f.lastActivityMs || 0) > 0
? `<span class="last-seen">Last posted ${formatTimeAgo(f.lastActivityMs)}</span>`
: `<span class="last-seen">No posts yet</span>`;
const viewBtn = `<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${f.nodeId}" data-name="${label}">Posts</button>`;
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
return `<div class="peer-card" data-node-id="${f.nodeId}">
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${f.nodeId}" data-name="${label}">${label}</a></div>
<div class="peer-card-lastseen">${activity}</div>
<div class="peer-card-bio"></div>
<div class="peer-card-actions">${viewBtn} ${msgBtn} ${unfollowBtn}</div>
</div>`;
};
followsList.innerHTML = sorted.map(renderFollowCard).join('');
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
toast('Unfollowed');
loadFollows();
loadStats();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
followsList.querySelectorAll('.view-posts-btn').forEach(btn => {
btn.addEventListener('click', () => {
openAuthorFeed(btn.dataset.nodeId, btn.dataset.name);
});
});
followsList.querySelectorAll('.bio-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
loadPeerBios(followsList);
}
// loadSuggested removed — Suggested Peers section removed from UI
async function loadDiscoverPeople() {
const container = $('#discover-list');
try {
const [peers, follows] = await Promise.all([
invoke('list_peers'),
invoke('list_follows'),
]);
const followSet = new Set(follows.map(f => f.nodeId));
// Filter: has display name, not already followed, not self
const discoverable = peers.filter(p =>
p.displayName && !followSet.has(p.nodeId) && p.nodeId !== myNodeId
);
if (discoverable.length === 0) {
// v0.6.2: Discover is driven by signed profile posts we've received
// via the CDN. No liveness / network-presence dependency — if we
// hold a profile post with a non-empty display_name from someone
// we don't follow and haven't ignored, they show up.
const profiles = await invoke('list_discover');
if (!profiles || profiles.length === 0) {
container.innerHTML = renderEmptyState(
'No new people found',
'Connect to more peers to discover people on the network.'
'No new named profiles yet',
'New people appear here as their signed profile posts propagate through the network.'
);
} else {
container.innerHTML = discoverable.map(p => {
const icon = generateIdenticon(p.nodeId, 18);
const label = escapeHtml(p.displayName);
let reachBadge = '';
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
return `<div class="peer-card" data-node-id="${p.nodeId}">
<div class="peer-card-row">${icon} ${label} ${reachBadge}</div>
<div class="peer-card-bio"></div>
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>
</div>
</div>`;
}).join('');
attachFollowHandlers(container);
loadPeerBios(container);
return;
}
container.innerHTML = profiles.map(p => {
const icon = generateIdenticon(p.nodeId, 18);
const label = escapeHtml(p.displayName);
const bioLine = p.bio
? `<div class="peer-card-bio" style="font-size:0.8rem;color:#aaa;margin-top:0.15rem">${escapeHtml(p.bio)}</div>`
: '';
const ageLine = p.updatedAtMs
? `<span class="last-seen">Profile updated ${formatTimeAgo(p.updatedAtMs)}</span>`
: '';
return `<div class="peer-card" data-node-id="${p.nodeId}">
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${p.nodeId}" data-name="${label}">${label}</a></div>
${bioLine}
${ageLine ? `<div class="peer-card-lastseen">${ageLine}</div>` : ''}
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${p.nodeId}" data-name="${label}">Posts</button>
<button class="btn btn-ghost btn-sm ignore-btn" data-node-id="${p.nodeId}" data-name="${label}">Ignore</button>
</div>
</div>`;
}).join('');
attachFollowHandlers(container);
container.querySelectorAll('.view-posts-btn').forEach(btn => {
btn.addEventListener('click', () => openAuthorFeed(btn.dataset.nodeId, btn.dataset.name));
});
container.querySelectorAll('.bio-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
container.querySelectorAll('.ignore-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Ignore ${btn.dataset.name || 'this peer'}? Their posts and profile will be hidden.`)) return;
btn.disabled = true;
try {
await invoke('ignore_peer', { nodeIdHex: btn.dataset.nodeId });
toast('Ignored');
loadDiscoverPeople();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
} catch (e) {
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
// ---- Bio modal + per-author feed filter ----
let authorFilterNodeId = null; // when set, feed shows only this author's posts
let authorFilterName = null;
async function openBioModal(nodeId, preloadedName) {
const overlay = document.getElementById('popover-overlay');
const titleEl = document.getElementById('popover-title');
const bodyEl = document.getElementById('popover-body');
if (!overlay || !titleEl || !bodyEl) return;
titleEl.textContent = preloadedName || (nodeId ? nodeId.slice(0, 12) : 'Profile');
bodyEl.innerHTML = '<p class="empty-hint">Loading…</p>';
overlay.classList.remove('hidden');
try {
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
const follows = await invoke('list_follows').catch(() => []);
const ignored = await invoke('list_ignored_peers').catch(() => []);
const following = follows.some(f => f.nodeId === nodeId);
const isIgnored = ignored.some(i => i.nodeId === nodeId);
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
const bio = (resolved && resolved.bio) || '';
const icon = generateIdenticon(nodeId, 48);
titleEl.textContent = name;
bodyEl.innerHTML = `
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
${icon}
<div style="flex:1;min-width:0">
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
</div>
</div>
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
${following
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
${isIgnored
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
: `<button id="bio-ignore" class="btn btn-danger btn-sm">Ignore</button>`}
</div>
`;
const close = () => overlay.classList.add('hidden');
const vp = document.getElementById('bio-view-posts');
if (vp) vp.onclick = () => { close(); openAuthorFeed(nodeId, name); };
const follow = document.getElementById('bio-follow');
if (follow) follow.onclick = async () => {
try { await invoke('follow_node', { nodeIdHex: nodeId }); toast('Followed'); close(); loadFollows(); loadFeed(true); }
catch (e) { toast('Error: ' + e); }
};
const unfollow = document.getElementById('bio-unfollow');
if (unfollow) unfollow.onclick = async () => {
try { await invoke('unfollow_node', { nodeIdHex: nodeId }); toast('Unfollowed'); close(); loadFollows(); loadFeed(true); }
catch (e) { toast('Error: ' + e); }
};
const msg = document.getElementById('bio-message');
if (msg) msg.onclick = () => {
close();
// Switch to Messages tab + preselect this recipient if possible.
const tab = document.querySelector('.tab[data-tab="messages"]');
if (tab) tab.click();
const sel = document.getElementById('dm-recipient-select');
if (sel) {
for (const opt of sel.options) {
if (opt.value === nodeId) { sel.value = nodeId; break; }
}
}
};
const ignore = document.getElementById('bio-ignore');
if (ignore) ignore.onclick = async () => {
if (!confirm(`Ignore ${name}? They'll be hidden from feeds and Discover.`)) return;
try { await invoke('ignore_peer', { nodeIdHex: nodeId }); toast('Ignored'); close(); loadFollows(); loadFeed(true); }
catch (e) { toast('Error: ' + e); }
};
const unignore = document.getElementById('bio-unignore');
if (unignore) unignore.onclick = async () => {
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
catch (e) { toast('Error: ' + e); }
};
} catch (e) {
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
function openAuthorFeed(nodeId, name) {
authorFilterNodeId = nodeId;
authorFilterName = name || nodeId.slice(0, 12);
const feedTab = document.querySelector('.tab[data-tab="feed"]');
if (feedTab) feedTab.click();
loadFeed(true);
}
function clearAuthorFilter() {
authorFilterNodeId = null;
authorFilterName = null;
loadFeed(true);
}
// Shared handler for follow/unfollow buttons in a container
function attachFollowHandlers(container) {
container.querySelectorAll('.follow-btn').forEach(btn => {
@ -2932,14 +3051,18 @@ document.querySelectorAll('.tab').forEach(tab => {
}
if (target === 'people') {
if (!followsList.children.length) followsList.innerHTML = renderLoading();
loadFollows(); loadAudience();
loadFollows();
loadAudience();
// Refresh Discover if its section is already expanded.
const disc = document.getElementById('discover-body');
if (disc && !disc.classList.contains('hidden')) loadDiscoverPeople();
}
if (target === 'messages') {
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
}
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); }
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); }
});
});
});
@ -3046,6 +3169,22 @@ $('#discover-toggle').addEventListener('click', () => {
if (!body.classList.contains('hidden')) loadDiscoverPeople();
});
// "See new activity" button in Following: applies staged data and
// re-renders so the user picks when to rearrange the list.
{
const refreshBtn = document.getElementById('follows-refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (_followsPendingData) {
renderFollowsList(_followsPendingData);
_followsDisplayedMaxActivity = _followsLatestMaxActivity;
_followsPendingData = null;
}
refreshBtn.classList.add('hidden');
});
}
}
$('#anchors-toggle').addEventListener('click', () => {
const body = $('#anchors-body');
body.classList.toggle('hidden');
@ -3442,6 +3581,43 @@ let personasCache = [];
// 'all' (show everything) or a posting-id hex (filter to that persona).
let feedPersonaFilter = 'all';
// --- Ignored peers list (Settings) ---
async function loadIgnored() {
const container = document.getElementById('ignored-list');
if (!container) return;
try {
const ignored = await invoke('list_ignored_peers');
if (!ignored || ignored.length === 0) {
container.innerHTML = '<p class="empty-hint" style="margin:0">No ignored peers.</p>';
return;
}
container.innerHTML = ignored.map(i => {
const label = escapeHtml(i.displayName || i.nodeId.slice(0, 12));
const icon = generateIdenticon(i.nodeId, 18);
return `<div class="peer-card" data-node-id="${i.nodeId}">
<div class="peer-card-row">${icon} ${label}</div>
<div class="peer-card-actions">
<button class="btn btn-ghost btn-sm unignore-btn" data-node-id="${i.nodeId}">Unignore</button>
</div>
</div>`;
}).join('');
container.querySelectorAll('.unignore-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('unignore_peer', { nodeIdHex: btn.dataset.nodeId });
toast('Unignored');
loadIgnored();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
} catch (e) {
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
// --- Release announcement / upgrade banner ---
async function loadUpgradeBanner() {
const banner = document.getElementById('upgrade-banner');