From 2ce668aa582be0726550844af9fed03f31cb1b72 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 23 Apr 2026 12:15:51 -0400 Subject: [PATCH] People tab rewrite: recency sort, profile-post Discover, bio modal, per-author feed filter, ignore primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/core/src/node.rs | 41 +++ crates/core/src/storage.rs | 129 ++++++++++ crates/tauri-app/src/lib.rs | 81 +++++- frontend/app.js | 498 ++++++++++++++++++++++++------------ frontend/index.html | 14 +- 5 files changed, 595 insertions(+), 168 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index b9fc10b..3a1614d 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1444,6 +1444,47 @@ impl Node { storage.list_follows() } + /// Batch: for each followed author, return the last-post timestamp we + /// hold locally. Used by the Following UI to sort by recency (which + /// replaces the broken "online" indicator since the network/posting + /// key split anonymized presence). + pub async fn last_activity_for_follows(&self) -> anyhow::Result> { + let storage = self.storage.get().await; + let follows = storage.list_follows()?; + storage.last_activity_for_authors(&follows) + } + + // ---- Ignored peers ---- + + pub async fn ignore_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + let storage = self.storage.get().await; + storage.add_ignored_peer(node_id)?; + // If the peer was in follows, also drop them — ignoring implies + // no-longer-following. Best-effort; errors are logged by callers. + let _ = storage.remove_follow(node_id); + let _ = storage.remove_social_route(node_id); + Ok(()) + } + + pub async fn unignore_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + let storage = self.storage.get().await; + storage.remove_ignored_peer(node_id) + } + + pub async fn list_ignored_peers(&self) -> anyhow::Result> { + let storage = self.storage.get().await; + storage.list_ignored_peers() + } + + // ---- Discover ---- + + /// Named peers we aren't following and haven't ignored — driven entirely + /// by signed profile posts we've received through the CDN. + pub async fn list_discoverable_profiles(&self) -> anyhow::Result> { + let storage = self.storage.get().await; + storage.list_discoverable_profiles(&self.default_posting_id) + } + // ---- Profiles ---- /// Set the default posting identity's profile (display_name, bio, diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 074e2dd..e5507ab 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -331,6 +331,14 @@ impl Storage { last_seen_ms INTEGER NOT NULL, success_count INTEGER NOT NULL DEFAULT 0 ); + -- v0.6.2: peers the user has chosen to hide. Their posts are + -- excluded from Feed / My Posts / Messages; their profiles are + -- excluded from Discover. One-way, local-only — nothing on the + -- wire announces that we've ignored them. + CREATE TABLE IF NOT EXISTS ignored_peers ( + node_id BLOB PRIMARY KEY, + ignored_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS blob_headers ( post_id BLOB PRIMARY KEY, author BLOB NOT NULL, @@ -926,6 +934,7 @@ impl Storage { let mut stmt = self.conn.prepare( "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"')) + AND author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { @@ -963,6 +972,7 @@ impl Storage { FROM posts p INNER JOIN follows f ON p.author = f.node_id WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"')) + AND p.author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY p.timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { @@ -1000,11 +1010,13 @@ impl Storage { FROM posts p INNER JOIN follows f ON p.author = f.node_id WHERE p.timestamp_ms < ?1 AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"')) + AND p.author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY p.timestamp_ms DESC LIMIT ?2" } else { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"')) + AND p.author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY p.timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; @@ -1023,11 +1035,13 @@ impl Storage { FROM posts WHERE timestamp_ms < ?1 AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"')) + AND author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY timestamp_ms DESC LIMIT ?2" } else { "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"')) + AND author NOT IN (SELECT node_id FROM ignored_peers) ORDER BY timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; @@ -1249,6 +1263,121 @@ impl Storage { Ok(ids) } + /// Return a map from each follow's NodeId to the most-recent + /// post-author timestamp stored locally for that id. Used by the + /// Following UI to sort by "last activity" (since online status was + /// lost when the network/posting key split hid presence information). + /// Authors with no known posts appear in the map with 0. + pub fn last_activity_for_authors(&self, authors: &[NodeId]) -> anyhow::Result> { + use std::collections::HashMap; + let mut out: HashMap = HashMap::with_capacity(authors.len()); + if authors.is_empty() { return Ok(out); } + let placeholders = (0..authors.len()).map(|i| format!("?{}", i + 1)).collect::>().join(","); + let sql = format!( + "SELECT author, MAX(timestamp_ms) FROM posts + WHERE author IN ({}) + AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"')) + GROUP BY author", + placeholders + ); + let params: Vec> = authors.iter().map(|n| Box::new(n.to_vec()) as Box).collect(); + let refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|b| b.as_ref()).collect(); + let mut stmt = self.conn.prepare(&sql)?; + let mut rows = stmt.query(refs.as_slice())?; + while let Some(row) = rows.next()? { + let author: Vec = row.get(0)?; + let ts: Option = row.get(1)?; + let nid = blob_to_nodeid(author)?; + out.insert(nid, ts.unwrap_or(0) as u64); + } + for a in authors { + out.entry(*a).or_insert(0); + } + Ok(out) + } + + // ---- v0.6.2 Ignored peers ---- + + pub fn add_ignored_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + self.conn.execute( + "INSERT INTO ignored_peers (node_id, ignored_at) VALUES (?1, ?2) + ON CONFLICT(node_id) DO UPDATE SET ignored_at = excluded.ignored_at", + params![node_id.as_slice(), now_ms() as i64], + )?; + Ok(()) + } + + pub fn remove_ignored_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM ignored_peers WHERE node_id = ?1", + params![node_id.as_slice()], + )?; + Ok(()) + } + + pub fn list_ignored_peers(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare("SELECT node_id FROM ignored_peers ORDER BY ignored_at DESC")?; + let rows = stmt.query_map([], |row| row.get::<_, Vec>(0))?; + let mut ids = Vec::new(); + for row in rows { ids.push(blob_to_nodeid(row?)?); } + Ok(ids) + } + + pub fn is_ignored_peer(&self, node_id: &NodeId) -> anyhow::Result { + let n: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM ignored_peers WHERE node_id = ?1", + params![node_id.as_slice()], + |row| row.get(0), + ).unwrap_or(0); + Ok(n > 0) + } + + /// v0.6.2 Discover: profiles we could follow — named (display_name != ""), + /// whose NodeId is not already in follows or ignored_peers, and not + /// ourselves. Sorted by profile freshness (most recently updated first). + pub fn list_discoverable_profiles(&self, self_id: &NodeId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT p.node_id, p.display_name, p.bio, p.updated_at, + p.anchors, p.recent_peers, p.preferred_peers, + p.public_visible, p.avatar_cid + FROM profiles p + WHERE p.display_name != '' + AND p.node_id != ?1 + AND p.public_visible = 1 + AND p.node_id NOT IN (SELECT node_id FROM follows) + AND p.node_id NOT IN (SELECT node_id FROM ignored_peers) + ORDER BY p.updated_at DESC" + )?; + let mut out = Vec::new(); + let mut rows = stmt.query(params![self_id.as_slice()])?; + while let Some(row) = rows.next()? { + let node_id = blob_to_nodeid(row.get::<_, Vec>(0)?)?; + let display_name: String = row.get(1)?; + let bio: String = row.get(2)?; + let updated_at = row.get::<_, i64>(3)? as u64; + let anchors = parse_anchors_json(&row.get::<_, String>(4).unwrap_or_default()); + let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_default()); + let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_default()); + let public_visible: i64 = row.get(7).unwrap_or(1); + let avatar_cid: Option> = row.get(8).ok(); + let avatar_cid = avatar_cid.and_then(|v| if v.len() == 32 { + let mut arr = [0u8; 32]; arr.copy_from_slice(&v); Some(arr) + } else { None }); + out.push(crate::types::PublicProfile { + node_id, + display_name, + bio, + updated_at, + anchors, + recent_peers, + preferred_peers, + public_visible: public_visible != 0, + avatar_cid, + }); + } + Ok(out) + } + // ---- Protocol v4: Per-Author Sync Tracking ---- /// Update the last_sync_ms timestamp for a followed author. diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 4589928..44e3c93 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1026,10 +1026,78 @@ async fn unfollow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result node.unfollow(&nid).await.map_err(|e| e.to_string()) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscoverProfileDto { + node_id: String, + display_name: String, + bio: String, + updated_at_ms: u64, + has_avatar: bool, +} + +/// Named peers we could follow, derived from signed profile posts we've +/// received via the CDN. Filters out self, follows, ignores, and unnamed +/// profiles. Replaces the old network-presence "Discover" path which +/// depended on peer liveness. +#[tauri::command] +async fn list_discover(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let profiles = node.list_discoverable_profiles().await.map_err(|e| e.to_string())?; + Ok(profiles.into_iter().map(|p| DiscoverProfileDto { + node_id: hex::encode(p.node_id), + display_name: p.display_name, + bio: p.bio, + updated_at_ms: p.updated_at, + has_avatar: p.avatar_cid.is_some(), + }).collect()) +} + +#[tauri::command] +async fn ignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.ignore_peer(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn unignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.unignore_peer(&nid).await.map_err(|e| e.to_string()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IgnoredPeerDto { + node_id: String, + display_name: Option, +} + +#[tauri::command] +async fn list_ignored_peers(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let ids = node.list_ignored_peers().await.map_err(|e| e.to_string())?; + let mut out = Vec::with_capacity(ids.len()); + for nid in &ids { + let display_name = match node.resolve_display_name(nid).await { + Ok((name, _, _)) if !name.is_empty() => Some(name), + _ => None, + }; + out.push(IgnoredPeerDto { node_id: hex::encode(nid), display_name }); + } + Ok(out) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let follows = node.list_follows().await.map_err(|e| e.to_string())?; + // v0.6.2: since posting identities are anonymized from network + // presence, "last activity" is now the last post we hold from that + // author rather than the network peer last-seen timestamp. One batch + // query for all follows. + let activity = node.last_activity_for_follows().await.unwrap_or_default(); let mut dtos = Vec::with_capacity(follows.len()); for nid in &follows { let display_name = match node.resolve_display_name(nid).await { @@ -1040,13 +1108,12 @@ async fn list_follows(state: State<'_, AppNode>) -> Result, String> let storage = node.storage.get().await; let rec = storage.get_peer_record(nid).ok().flatten(); drop(storage); + // Online presence is no longer reliable post-v0.6.1 (network id + // is decoupled from posting id we're showing). Frontend should + // treat this as a soft hint rather than truth. let is_online = node.network.is_connected(nid).await || node.network.has_session(nid).await; - let last_activity_ms = if let Some(arc) = node.network.conn_handle().get_peer_last_activity(nid).await { - arc.load(std::sync::atomic::Ordering::Relaxed) - } else { - rec.as_ref().map(|r| r.last_seen).unwrap_or(0) - }; + let last_activity_ms = *activity.get(nid).unwrap_or(&0); dtos.push(PeerDto { node_id: hex::encode(nid), display_name, @@ -3032,6 +3099,10 @@ pub fn run() { list_follows, list_peers, suggested_peers, + list_discover, + ignore_peer, + unignore_peer, + list_ignored_peers, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index bcf175a..e01fe3b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 + ? `
+ Showing posts from ${escapeHtml(authorFilterName || authorFilterNodeId.slice(0, 12))} + +
` + : ''; + 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 = `
${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}
`; - } 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 = '(you)'; - } else { - if (!f.isOnline && f.lastActivityMs > 0) { - lastSeenHtml = `Last online: ${formatTimeAgo(f.lastActivityMs)}`; - } - const syncBtn = ``; - const msgBtn = ``; - const unfollowBtn = ``; - actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`; - } - return `
-
${icon} ${label}
- ${lastSeenHtml ? `
${lastSeenHtml}
` : ''} -
-
${actions}
-
`; - }; - - // 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 += `
Following: Online (${online.length})
`; - html += online.map(renderFollowCard).join(''); - } - if (offline.length > 0) { - html += `
Following: Offline (${offline.length})
`; - } - 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 = ` -
-
-

Following: Offline (${offline.length})

- -
-
${offline.map(renderFollowCard).join('')}
-
`; - 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 = `
Error: ${e}
`; } } +function renderFollowsList(others) { + if (others.length === 0) { + followsList.innerHTML = `
${renderEmptyState('Not following anyone', 'Discover named people below or connect manually.')}
`; + 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 + ? `Last posted ${formatTimeAgo(f.lastActivityMs)}` + : `No posts yet`; + const viewBtn = ``; + const msgBtn = ``; + const unfollowBtn = ``; + return `
+
${icon} ${label}
+
${activity}
+
+
${viewBtn} ${msgBtn} ${unfollowBtn}
+
`; + }; + + 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 = 'Mesh'; - else if (p.reach === 'n1') reachBadge = 'N1'; - else if (p.reach === 'n2') reachBadge = 'N2'; - else if (p.reach === 'n3') reachBadge = 'N3'; - - return `
-
${icon} ${label} ${reachBadge}
-
-
- - -
-
`; - }).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 + ? `
${escapeHtml(p.bio)}
` + : ''; + const ageLine = p.updatedAtMs + ? `Profile updated ${formatTimeAgo(p.updatedAtMs)}` + : ''; + return `
+
${icon} ${label}
+ ${bioLine} + ${ageLine ? `
${ageLine}
` : ''} +
+ + + +
+
`; + }).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 = `

Error: ${e}

`; } } +// ---- 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 = '

Loading…

'; + 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 = ` +
+ ${icon} +
+
${escapeHtml(name)}
+
${nodeId}
+
+
+ ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} +
+ + ${following + ? `` + : ``} + + ${isIgnored + ? `` + : ``} +
+ `; + + 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 = `

Error: ${e}

`; + } +} + +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 = '

No ignored peers.

'; + return; + } + container.innerHTML = ignored.map(i => { + const label = escapeHtml(i.displayName || i.nodeId.slice(0, 12)); + const icon = generateIdenticon(i.nodeId, 18); + return `
+
${icon} ${label}
+
+ +
+
`; + }).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 = `

Error: ${e}

`; + } +} + // --- Release announcement / upgrade banner --- async function loadUpgradeBanner() { const banner = document.getElementById('upgrade-banner'); diff --git a/frontend/index.html b/frontend/index.html index e3de74d..87d9c83 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -127,14 +127,18 @@
-

Following

+
+

Following

+ +
+

Sorted by last post. Tap a name to see their bio.

@@ -188,6 +192,12 @@ +
+

Ignored

+

Peers whose posts and profiles are hidden from Feed, Messages, and Discover. Local-only; nothing on the wire says you've ignored them.

+
+
+

Updates

Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.