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.