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()
|
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 ----
|
// ---- Profiles ----
|
||||||
|
|
||||||
/// Set the default posting identity's profile (display_name, bio,
|
/// Set the default posting identity's profile (display_name, bio,
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,14 @@ impl Storage {
|
||||||
last_seen_ms INTEGER NOT NULL,
|
last_seen_ms INTEGER NOT NULL,
|
||||||
success_count INTEGER NOT NULL DEFAULT 0
|
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 (
|
CREATE TABLE IF NOT EXISTS blob_headers (
|
||||||
post_id BLOB PRIMARY KEY,
|
post_id BLOB PRIMARY KEY,
|
||||||
author BLOB NOT NULL,
|
author BLOB NOT NULL,
|
||||||
|
|
@ -926,6 +934,7 @@ impl Storage {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
|
"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\"'))
|
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",
|
ORDER BY timestamp_ms DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
|
|
@ -963,6 +972,7 @@ impl Storage {
|
||||||
FROM posts p
|
FROM posts p
|
||||||
INNER JOIN follows f ON p.author = f.node_id
|
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\"'))
|
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",
|
ORDER BY p.timestamp_ms DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
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
|
FROM posts p INNER JOIN follows f ON p.author = f.node_id
|
||||||
WHERE p.timestamp_ms < ?1
|
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.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"
|
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||||
} else {
|
} else {
|
||||||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
"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
|
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\"'))
|
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"
|
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||||
};
|
};
|
||||||
let mut stmt = self.conn.prepare(sql)?;
|
let mut stmt = self.conn.prepare(sql)?;
|
||||||
|
|
@ -1023,11 +1035,13 @@ impl Storage {
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE timestamp_ms < ?1
|
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 (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"
|
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
|
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"
|
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||||
};
|
};
|
||||||
let mut stmt = self.conn.prepare(sql)?;
|
let mut stmt = self.conn.prepare(sql)?;
|
||||||
|
|
@ -1249,6 +1263,121 @@ impl Storage {
|
||||||
Ok(ids)
|
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 ----
|
// ---- Protocol v4: Per-Author Sync Tracking ----
|
||||||
|
|
||||||
/// Update the last_sync_ms timestamp for a followed author.
|
/// Update the last_sync_ms timestamp for a followed author.
|
||||||
|
|
|
||||||
|
|
@ -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())
|
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]
|
#[tauri::command]
|
||||||
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
let follows = node.list_follows().await.map_err(|e| e.to_string())?;
|
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());
|
let mut dtos = Vec::with_capacity(follows.len());
|
||||||
for nid in &follows {
|
for nid in &follows {
|
||||||
let display_name = match node.resolve_display_name(nid).await {
|
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 storage = node.storage.get().await;
|
||||||
let rec = storage.get_peer_record(nid).ok().flatten();
|
let rec = storage.get_peer_record(nid).ok().flatten();
|
||||||
drop(storage);
|
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
|
let is_online = node.network.is_connected(nid).await
|
||||||
|| node.network.has_session(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 {
|
let last_activity_ms = *activity.get(nid).unwrap_or(&0);
|
||||||
arc.load(std::sync::atomic::Ordering::Relaxed)
|
|
||||||
} else {
|
|
||||||
rec.as_ref().map(|r| r.last_seen).unwrap_or(0)
|
|
||||||
};
|
|
||||||
dtos.push(PeerDto {
|
dtos.push(PeerDto {
|
||||||
node_id: hex::encode(nid),
|
node_id: hex::encode(nid),
|
||||||
display_name,
|
display_name,
|
||||||
|
|
@ -3032,6 +3099,10 @@ pub fn run() {
|
||||||
list_follows,
|
list_follows,
|
||||||
list_peers,
|
list_peers,
|
||||||
suggested_peers,
|
suggested_peers,
|
||||||
|
list_discover,
|
||||||
|
ignore_peer,
|
||||||
|
unignore_peer,
|
||||||
|
list_ignored_peers,
|
||||||
list_circles,
|
list_circles,
|
||||||
create_circle,
|
create_circle,
|
||||||
delete_circle,
|
delete_circle,
|
||||||
|
|
|
||||||
498
frontend/app.js
498
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
|
let _feedPostIds = new Set(); // track loaded post IDs to avoid duplicates
|
||||||
|
|
||||||
function filterFeedPosts(posts) {
|
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) {
|
async function loadFeed(force) {
|
||||||
|
|
@ -769,11 +774,29 @@ async function loadFeed(force) {
|
||||||
_feedHasMore = result.hasMore;
|
_feedHasMore = result.hasMore;
|
||||||
_feedPostIds = new Set(posts.map(p => p.id));
|
_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) {
|
if (posts.length === 0) {
|
||||||
_feedFingerprint = null;
|
_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 {
|
} 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
|
// Add scroll sentinel at midpoint
|
||||||
if (_feedHasMore) {
|
if (_feedHasMore) {
|
||||||
const sentinel = document.createElement('div');
|
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() {
|
async function loadFollows() {
|
||||||
try {
|
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');
|
const follows = await invoke('list_follows');
|
||||||
|
|
||||||
// Filter out self before rendering
|
|
||||||
const others = follows.filter(f => f.nodeId !== myNodeId);
|
const others = follows.filter(f => f.nodeId !== myNodeId);
|
||||||
if (others.length === 0) {
|
const newMax = others.reduce((m, f) => Math.max(m, f.lastActivityMs || 0), 0);
|
||||||
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
|
|
||||||
} else {
|
|
||||||
const now = Date.now();
|
|
||||||
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
||||||
const renderFollowCard = (f) => {
|
|
||||||
const icon = generateIdenticon(f.nodeId, 18);
|
|
||||||
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
|
||||||
const isSelf = f.nodeId === myNodeId;
|
|
||||||
|
|
||||||
let lastSeenHtml = '';
|
if (_followsDisplayedMaxActivity === 0) {
|
||||||
let actions = '';
|
_followsDisplayedMaxActivity = newMax;
|
||||||
if (isSelf) {
|
|
||||||
actions = '<span class="self-tag">(you)</span>';
|
|
||||||
} else {
|
|
||||||
if (!f.isOnline && f.lastActivityMs > 0) {
|
|
||||||
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
|
|
||||||
}
|
|
||||||
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
|
|
||||||
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
|
|
||||||
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
|
|
||||||
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
|
||||||
}
|
|
||||||
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
|
||||||
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a></div>
|
|
||||||
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
|
|
||||||
<div class="peer-card-bio"></div>
|
|
||||||
<div class="peer-card-actions">${actions}</div>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// If isOnline field isn't available (old build), show all as online
|
|
||||||
const hasOnlineField = others.some(f => f.isOnline !== undefined);
|
|
||||||
const online = hasOnlineField
|
|
||||||
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
|
|
||||||
: others;
|
|
||||||
const offline = hasOnlineField
|
|
||||||
? others.filter(f => !online.includes(f))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
updateTabBadge('people', online.length);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
if (online.length > 0) {
|
|
||||||
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
|
||||||
html += online.map(renderFollowCard).join('');
|
|
||||||
}
|
|
||||||
if (offline.length > 0) {
|
|
||||||
html += `<div class="follows-section-header follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
|
|
||||||
}
|
|
||||||
followsList.innerHTML = html;
|
|
||||||
|
|
||||||
// Open offline follows in lightbox
|
|
||||||
if (offline.length > 0) {
|
|
||||||
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
|
|
||||||
hdr.addEventListener('click', () => {
|
|
||||||
const existing = document.querySelector('.offline-lightbox');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'offline-lightbox';
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="offline-lightbox-content">
|
|
||||||
<div class="offline-lightbox-header">
|
|
||||||
<h3>Following: Offline (${offline.length})</h3>
|
|
||||||
<button class="offline-lightbox-close">x</button>
|
|
||||||
</div>
|
|
||||||
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
|
|
||||||
</div>`;
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
|
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
||||||
|
|
||||||
// Wire up buttons inside the lightbox
|
|
||||||
attachFollowHandlers(overlay);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach unfollow handlers
|
|
||||||
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
|
||||||
toast('Unfollowed');
|
|
||||||
loadFollows();
|
|
||||||
|
|
||||||
loadStats();
|
|
||||||
loadFeed(true);
|
|
||||||
} catch (e) {
|
|
||||||
toast('Error: ' + e);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach per-peer sync handlers
|
|
||||||
followsList.querySelectorAll('.sync-peer-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Syncing...';
|
|
||||||
try {
|
|
||||||
await invoke('sync_from_peer', { nodeIdHex: btn.dataset.nodeId });
|
|
||||||
toast('Sync complete!');
|
|
||||||
loadFeed(true);
|
|
||||||
loadMyPosts(true);
|
|
||||||
} catch (e) {
|
|
||||||
toast('Error: ' + e);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Sync';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lazy-load bios
|
|
||||||
loadPeerBios(followsList);
|
|
||||||
}
|
}
|
||||||
|
_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) {
|
} catch (e) {
|
||||||
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFollowsList(others) {
|
||||||
|
if (others.length === 0) {
|
||||||
|
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Discover named people below or connect manually.')}</div>`;
|
||||||
|
updateTabBadge('people', 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by last activity DESC; never-posted authors go to the bottom.
|
||||||
|
const sorted = [...others].sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||||
|
updateTabBadge('people', 0);
|
||||||
|
|
||||||
|
const renderFollowCard = (f) => {
|
||||||
|
const icon = generateIdenticon(f.nodeId, 18);
|
||||||
|
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
||||||
|
const activity = (f.lastActivityMs || 0) > 0
|
||||||
|
? `<span class="last-seen">Last posted ${formatTimeAgo(f.lastActivityMs)}</span>`
|
||||||
|
: `<span class="last-seen">No posts yet</span>`;
|
||||||
|
const viewBtn = `<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${f.nodeId}" data-name="${label}">Posts</button>`;
|
||||||
|
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
|
||||||
|
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
|
||||||
|
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
||||||
|
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${f.nodeId}" data-name="${label}">${label}</a></div>
|
||||||
|
<div class="peer-card-lastseen">${activity}</div>
|
||||||
|
<div class="peer-card-bio"></div>
|
||||||
|
<div class="peer-card-actions">${viewBtn} ${msgBtn} ${unfollowBtn}</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
followsList.innerHTML = sorted.map(renderFollowCard).join('');
|
||||||
|
|
||||||
|
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
||||||
|
toast('Unfollowed');
|
||||||
|
loadFollows();
|
||||||
|
loadStats();
|
||||||
|
loadFeed(true);
|
||||||
|
} catch (e) { toast('Error: ' + e); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
followsList.querySelectorAll('.view-posts-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
openAuthorFeed(btn.dataset.nodeId, btn.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
followsList.querySelectorAll('.bio-link').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadPeerBios(followsList);
|
||||||
|
}
|
||||||
|
|
||||||
// loadSuggested removed — Suggested Peers section removed from UI
|
// loadSuggested removed — Suggested Peers section removed from UI
|
||||||
|
|
||||||
async function loadDiscoverPeople() {
|
async function loadDiscoverPeople() {
|
||||||
const container = $('#discover-list');
|
const container = $('#discover-list');
|
||||||
try {
|
try {
|
||||||
const [peers, follows] = await Promise.all([
|
// v0.6.2: Discover is driven by signed profile posts we've received
|
||||||
invoke('list_peers'),
|
// via the CDN. No liveness / network-presence dependency — if we
|
||||||
invoke('list_follows'),
|
// hold a profile post with a non-empty display_name from someone
|
||||||
]);
|
// we don't follow and haven't ignored, they show up.
|
||||||
const followSet = new Set(follows.map(f => f.nodeId));
|
const profiles = await invoke('list_discover');
|
||||||
|
if (!profiles || profiles.length === 0) {
|
||||||
// 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) {
|
|
||||||
container.innerHTML = renderEmptyState(
|
container.innerHTML = renderEmptyState(
|
||||||
'No new people found',
|
'No new named profiles yet',
|
||||||
'Connect to more peers to discover people on the network.'
|
'New people appear here as their signed profile posts propagate through the network.'
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
container.innerHTML = discoverable.map(p => {
|
|
||||||
const icon = generateIdenticon(p.nodeId, 18);
|
|
||||||
const label = escapeHtml(p.displayName);
|
|
||||||
let reachBadge = '';
|
|
||||||
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
|
||||||
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
|
||||||
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
|
||||||
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
|
||||||
|
|
||||||
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
|
||||||
<div class="peer-card-row">${icon} ${label} ${reachBadge}</div>
|
|
||||||
<div class="peer-card-bio"></div>
|
|
||||||
<div class="peer-card-actions">
|
|
||||||
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
|
||||||
<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
attachFollowHandlers(container);
|
|
||||||
loadPeerBios(container);
|
|
||||||
}
|
}
|
||||||
|
container.innerHTML = profiles.map(p => {
|
||||||
|
const icon = generateIdenticon(p.nodeId, 18);
|
||||||
|
const label = escapeHtml(p.displayName);
|
||||||
|
const bioLine = p.bio
|
||||||
|
? `<div class="peer-card-bio" style="font-size:0.8rem;color:#aaa;margin-top:0.15rem">${escapeHtml(p.bio)}</div>`
|
||||||
|
: '';
|
||||||
|
const ageLine = p.updatedAtMs
|
||||||
|
? `<span class="last-seen">Profile updated ${formatTimeAgo(p.updatedAtMs)}</span>`
|
||||||
|
: '';
|
||||||
|
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
||||||
|
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${p.nodeId}" data-name="${label}">${label}</a></div>
|
||||||
|
${bioLine}
|
||||||
|
${ageLine ? `<div class="peer-card-lastseen">${ageLine}</div>` : ''}
|
||||||
|
<div class="peer-card-actions">
|
||||||
|
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
||||||
|
<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${p.nodeId}" data-name="${label}">Posts</button>
|
||||||
|
<button class="btn btn-ghost btn-sm ignore-btn" data-node-id="${p.nodeId}" data-name="${label}">Ignore</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
attachFollowHandlers(container);
|
||||||
|
container.querySelectorAll('.view-posts-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => openAuthorFeed(btn.dataset.nodeId, btn.dataset.name));
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.bio-link').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.ignore-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Ignore ${btn.dataset.name || 'this peer'}? Their posts and profile will be hidden.`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('ignore_peer', { nodeIdHex: btn.dataset.nodeId });
|
||||||
|
toast('Ignored');
|
||||||
|
loadDiscoverPeople();
|
||||||
|
loadFeed(true);
|
||||||
|
} catch (e) { toast('Error: ' + e); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
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
|
// Shared handler for follow/unfollow buttons in a container
|
||||||
function attachFollowHandlers(container) {
|
function attachFollowHandlers(container) {
|
||||||
container.querySelectorAll('.follow-btn').forEach(btn => {
|
container.querySelectorAll('.follow-btn').forEach(btn => {
|
||||||
|
|
@ -2932,14 +3051,18 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
}
|
}
|
||||||
if (target === 'people') {
|
if (target === 'people') {
|
||||||
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
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 (target === 'messages') {
|
||||||
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
||||||
loadMessages(true); loadDmRecipientOptions();
|
loadMessages(true); loadDmRecipientOptions();
|
||||||
clearNotifications('msg-');
|
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();
|
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', () => {
|
$('#anchors-toggle').addEventListener('click', () => {
|
||||||
const body = $('#anchors-body');
|
const body = $('#anchors-body');
|
||||||
body.classList.toggle('hidden');
|
body.classList.toggle('hidden');
|
||||||
|
|
@ -3442,6 +3581,43 @@ let personasCache = [];
|
||||||
// 'all' (show everything) or a posting-id hex (filter to that persona).
|
// 'all' (show everything) or a posting-id hex (filter to that persona).
|
||||||
let feedPersonaFilter = 'all';
|
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 ---
|
// --- Release announcement / upgrade banner ---
|
||||||
async function loadUpgradeBanner() {
|
async function loadUpgradeBanner() {
|
||||||
const banner = document.getElementById('upgrade-banner');
|
const banner = document.getElementById('upgrade-banner');
|
||||||
|
|
|
||||||
|
|
@ -127,14 +127,18 @@
|
||||||
<!-- People tab -->
|
<!-- People tab -->
|
||||||
<section id="view-people" class="view">
|
<section id="view-people" class="view">
|
||||||
<div class="section-card">
|
<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 id="follows-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
|
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
|
||||||
<div id="discover-body" class="hidden">
|
<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 id="discover-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,6 +192,12 @@
|
||||||
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></div>
|
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></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">
|
<div class="section-card" style="text-align:center">
|
||||||
<h3 style="margin-bottom:0.25rem">Updates</h3>
|
<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>
|
<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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue