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

@ -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<std::collections::HashMap<NodeId, u64>> {
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<Vec<NodeId>> {
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<Vec<PublicProfile>> {
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,

View file

@ -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<std::collections::HashMap<NodeId, u64>> {
use std::collections::HashMap;
let mut out: HashMap<NodeId, u64> = HashMap::with_capacity(authors.len());
if authors.is_empty() { return Ok(out); }
let placeholders = (0..authors.len()).map(|i| format!("?{}", i + 1)).collect::<Vec<_>>().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<Box<dyn rusqlite::ToSql>> = authors.iter().map(|n| Box::new(n.to_vec()) as Box<dyn rusqlite::ToSql>).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<u8> = row.get(0)?;
let ts: Option<i64> = 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<Vec<NodeId>> {
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<u8>>(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<bool> {
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<Vec<crate::types::PublicProfile>> {
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<u8>>(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<Vec<u8>> = 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.

View file

@ -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<Vec<DiscoverProfileDto>, 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<String>,
}
#[tauri::command]
async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeerDto>, 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<Vec<PeerDto>, 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<Vec<PeerDto>, 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,

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,92 +1443,82 @@ 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);
const newMax = others.reduce((m, f) => Math.max(m, f.lastActivityMs || 0), 0);
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', 'Follow suggested peers or connect manually.')}</div>`;
} else {
const now = Date.now();
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
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 isSelf = f.nodeId === myNodeId;
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 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>`;
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-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">${actions}</div>
<div class="peer-card-actions">${viewBtn} ${msgBtn} ${unfollowBtn}</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))
: [];
followsList.innerHTML = sorted.map(renderFollowCard).join('');
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;
@ -1513,42 +1526,25 @@ async function loadFollows() {
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
toast('Unfollowed');
loadFollows();
loadStats();
loadFeed(true);
} catch (e) {
toast('Error: ' + e);
} finally {
btn.disabled = false;
}
} 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);
});
});
// 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);
}
} catch (e) {
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
}
}
// loadSuggested removed — Suggested Peers section removed from UI
@ -1556,50 +1552,173 @@ async function loadFollows() {
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 => {
return;
}
container.innerHTML = profiles.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>';
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} ${label} ${reachBadge}</div>
<div class="peer-card-bio"></div>
<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 msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</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);
loadPeerBios(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');

View file

@ -127,14 +127,18 @@
<!-- People tab -->
<section id="view-people" class="view">
<div class="section-card">
<h3>Following</h3>
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;margin-bottom:0.4rem">
<h3 style="margin:0">Following</h3>
<button id="follows-refresh-btn" class="btn btn-ghost btn-sm hidden" style="background:#1f3f38;color:#7fdbca;border:1px solid #7fdbca">See new activity</button>
</div>
<p class="empty-hint" style="margin:0 0 0.5rem;font-size:0.75rem">Sorted by last post. Tap a name to see their bio.</p>
<div id="follows-list"></div>
</div>
<div class="section-card">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
<div id="discover-body" class="hidden">
<p class="empty-hint" style="margin-bottom:0.5rem">People on the network with profiles you can follow.</p>
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
<div id="discover-list"></div>
</div>
</div>
@ -188,6 +192,12 @@
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.25rem">Ignored</h3>
<p class="empty-hint" style="margin-bottom:0.5rem">Peers whose posts and profiles are hidden from Feed, Messages, and Discover. Local-only; nothing on the wire says you've ignored them.</p>
<div id="ignored-list" style="text-align:left"></div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.25rem">Updates</h3>
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>