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:
parent
e74bd4e6c6
commit
2ce668aa58
5 changed files with 595 additions and 168 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue