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

@ -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,