diff --git a/src/action.rs b/src/action.rs index 53451d2..ae64cc0 100644 --- a/src/action.rs +++ b/src/action.rs @@ -74,6 +74,8 @@ pub enum Action { members: Vec, admins: Vec, }, + GroupRelaysLoaded(Vec), + AccountRelaysLoaded(Vec), InvitesLoaded(Vec), GroupActionSuccess(String), GroupActionError(String), @@ -88,7 +90,6 @@ pub enum Action { // Settings SettingsLoaded(Value), - SettingsUpdateSuccess(String), SettingsUpdateError(String), // Follows @@ -110,6 +111,10 @@ pub enum Action { LogoutSuccess, LogoutError(String), + // Relay health + RelayHealthLoaded(Value), + RelayHealthError(String), + // Logs Log(String), DaemonLog(String), @@ -173,6 +178,13 @@ pub enum Effect { account: String, group_id: String, }, + LoadGroupRelays { + account: String, + group_id: String, + }, + LoadAccountRelays { + account: String, + }, LoadInvites { account: String, }, @@ -233,12 +245,6 @@ pub enum Effect { LoadSettings { account: String, }, - #[allow(dead_code)] - UpdateSetting { - account: String, - key: String, - value: String, - }, // Follows LoadFollows { @@ -292,6 +298,9 @@ pub enum Effect { file_path: String, }, + // Relay health + LoadRelayHealth, + // Daemon logs TailDaemonLog, } diff --git a/src/app.rs b/src/app.rs index ee0d4ee..fd50dc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -98,6 +98,14 @@ pub enum SearchPurpose { AddMember { group_id: String }, } +/// Relay health display mode. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum HealthView { + #[default] + ByStatus, + ByPlane, +} + /// State of a media download. #[derive(Debug, Clone)] pub enum MediaDownload { @@ -127,12 +135,16 @@ pub struct App { // Main screen pub focus: Panel, pub chats: Vec, + pub chats_loading: bool, pub selected_chat: usize, pub active_group_id: Option, pub messages: Vec, + pub messages_loading: bool, pub message_scroll: usize, pub selected_message: Option, pub message_viewport_height: usize, + /// Index range of messages currently visible in the viewport (updated each render). + pub visible_msg_range: (usize, usize), pub composer: Input, /// Active reply context: (event_id, author_name, content_preview). pub reply_to: Option<(String, String, String)>, @@ -148,6 +160,8 @@ pub struct App { pub group_detail: Option, pub group_members: Vec, pub group_admins: Vec, + pub group_relays: Vec, + pub account_relays: Vec, pub selected_member: usize, // Popup @@ -161,16 +175,16 @@ pub struct App { // Settings pub settings_data: Option, - #[allow(dead_code)] - pub selected_setting: usize, // Follows pub follows: Vec, + pub follows_loading: bool, pub selected_follow: usize, // User search pub search_input: Input, pub search_results: Vec, + pub search_loading: bool, pub selected_result: usize, pub search_purpose: SearchPurpose, pub follow_checks: HashMap, @@ -179,6 +193,13 @@ pub struct App { pub media_downloads: HashMap, pub inline_images: HashMap, + // Relay health + pub relay_health: Option, + pub health_error: Option, + pub health_scroll: usize, + pub health_max_scroll: usize, + pub health_view: HealthView, + // Log panel pub show_logs: bool, pub logs: Vec, @@ -202,12 +223,15 @@ impl App { status_message: None, focus: Panel::ChatList, chats: Vec::new(), + chats_loading: false, selected_chat: 0, active_group_id: None, messages: Vec::new(), + messages_loading: false, message_scroll: 0, selected_message: None, message_viewport_height: 20, // updated each render + visible_msg_range: (0, 0), composer: Input::new(), reply_to: None, unread_counts: HashMap::new(), @@ -216,6 +240,8 @@ impl App { group_detail: None, group_members: Vec::new(), group_admins: Vec::new(), + group_relays: Vec::new(), + account_relays: Vec::new(), selected_member: 0, popup: None, profile: None, @@ -223,16 +249,22 @@ impl App { profile_image: None, popup_image: None, settings_data: None, - selected_setting: 0, follows: Vec::new(), + follows_loading: false, selected_follow: 0, search_input: Input::new(), search_results: Vec::new(), + search_loading: false, selected_result: 0, search_purpose: SearchPurpose::Browse, follow_checks: HashMap::new(), media_downloads: HashMap::new(), inline_images: HashMap::new(), + relay_health: None, + health_error: None, + health_scroll: 0, + health_max_scroll: 0, + health_view: HealthView::default(), show_logs: false, logs: Vec::new(), daemon_logs: Vec::new(), @@ -273,10 +305,12 @@ impl App { // Chat streaming Action::ChatUpdate(val) => { self.connected = true; + self.chats_loading = false; self.handle_chat_update(val); } Action::ChatStreamEnded => { self.connected = false; + self.chats_loading = false; // Auto-reconnect if we have an account if let Some(account) = &self.account { return vec![Effect::SubscribeChats { @@ -288,14 +322,18 @@ impl App { // Message streaming Action::MessageUpdate { group_id, message } => { if self.active_group_id.as_deref() == Some(&group_id) { + self.messages_loading = false; return self.handle_message_update(message); } } - Action::MessageStreamEnded => {} + Action::MessageStreamEnded => { + self.messages_loading = false; + } // Send Action::MessageSent => {} Action::MessageSendError(msg) => { + self.messages_loading = false; self.popup = Some(Popup::Error { message: format!("Send failed: {msg}"), }); @@ -312,6 +350,7 @@ impl App { } } Action::MessagesLoaded(msgs) => { + self.messages_loading = false; self.messages = msgs; let mut effects = Vec::new(); for msg in &self.messages { @@ -441,6 +480,12 @@ impl App { self.group_admins = admins; self.selected_member = 0; } + Action::GroupRelaysLoaded(relays) => { + self.group_relays = relays; + } + Action::AccountRelaysLoaded(relays) => { + self.account_relays = relays; + } Action::InvitesLoaded(invites) => { if invites.is_empty() { self.status_message = Some("No pending invites".into()); @@ -554,22 +599,24 @@ impl App { Action::SettingsLoaded(val) => { self.settings_data = Some(val); } - Action::SettingsUpdateSuccess(msg) => { - self.status_message = Some(msg); - if let Some(account) = &self.account { - return vec![Effect::LoadSettings { - account: account.clone(), - }]; - } - } Action::SettingsUpdateError(msg) => { self.popup = Some(Popup::Error { message: format!("Error: {msg}"), }); } + // Relay health + Action::RelayHealthLoaded(val) => { + self.relay_health = Some(val); + self.health_error = None; + } + Action::RelayHealthError(msg) => { + self.health_error = Some(msg); + } + // Follows Action::FollowsLoaded(list) => { + self.follows_loading = false; self.follows = list; if self.selected_follow >= self.follows.len() { self.selected_follow = self.follows.len().saturating_sub(1); @@ -592,6 +639,7 @@ impl App { // User search Action::SearchResult(val) => { + self.search_loading = false; let pubkey = val .get("pubkey") .or_else(|| val.get("npub")) @@ -607,7 +655,9 @@ impl App { } } } - Action::SearchStreamEnded => {} + Action::SearchStreamEnded => { + self.search_loading = false; + } Action::UserProfileLoaded(data) => { let url = data .get("metadata") @@ -667,6 +717,10 @@ impl App { account: account.clone(), group_id: group_id.clone(), }, + Effect::LoadGroupRelays { + account: account.clone(), + group_id: group_id.clone(), + }, ] } else { vec![] @@ -695,7 +749,9 @@ impl App { self.login_mode = LoginMode::Menu; self.focus = Panel::ChatList; self.chats.clear(); + self.chats_loading = true; self.messages.clear(); + self.messages_loading = false; self.active_group_id = None; self.unread_counts.clear(); self.connected = false; @@ -740,7 +796,9 @@ impl App { self.account = None; self.status_message = None; self.chats.clear(); + self.chats_loading = false; self.messages.clear(); + self.messages_loading = false; self.active_group_id = None; self.unread_counts.clear(); self.connected = false; @@ -748,15 +806,22 @@ impl App { self.profile = None; self.profile_image = None; self.follows.clear(); + self.follows_loading = false; self.follow_checks.clear(); self.viewing_group_id = None; self.group_detail = None; self.group_members.clear(); self.group_admins.clear(); + self.group_relays.clear(); + self.account_relays.clear(); self.media_downloads.clear(); self.inline_images.clear(); self.reply_to = None; - vec![Effect::CheckAccounts] + vec![ + Effect::UnsubscribeMessages, + Effect::UnsubscribeSearch, + Effect::CheckAccounts, + ] } fn handle_logout(&mut self) -> Vec { @@ -778,6 +843,7 @@ impl App { fn clear_search_results(&mut self) { self.search_results.clear(); + self.search_loading = false; self.follow_checks.clear(); self.selected_result = 0; } @@ -973,7 +1039,9 @@ impl App { } // Tab switches log tabs when log panel is visible (on screens where Tab isn't used) - if key.code == KeyCode::Tab && self.show_logs && !matches!(self.screen, Screen::UserSearch) + if key.code == KeyCode::Tab + && self.show_logs + && !matches!(self.screen, Screen::UserSearch | Screen::Health) { self.log_tab = match self.log_tab { LogTab::Activity => LogTab::Daemon, @@ -990,6 +1058,7 @@ impl App { Screen::Profile => self.handle_profile_key(key), Screen::Settings => self.handle_settings_key(key), Screen::UserSearch => self.handle_search_key(key), + Screen::Health => self.handle_health_key(key), } } @@ -1490,10 +1559,15 @@ impl App { self.screen = Screen::Profile; self.profile = None; self.selected_follow = 0; - let acct = account.clone(); + self.follows_loading = true; vec![ - Effect::LoadProfile { account }, - Effect::LoadFollows { account: acct }, + Effect::LoadProfile { + account: account.clone(), + }, + Effect::LoadFollows { + account: account.clone(), + }, + Effect::LoadAccountRelays { account }, ] } KeyCode::Char('S') => { @@ -1522,6 +1596,14 @@ impl App { // (does NOT call wn logout — just navigates to the picker) self.reset_to_account_select() } + KeyCode::Char('h') => { + self.screen = Screen::Health; + self.relay_health = None; + self.health_error = None; + self.health_scroll = 0; + self.health_view = HealthView::default(); + vec![Effect::LoadRelayHealth] + } KeyCode::Char('q') => { self.running = false; vec![] @@ -1549,6 +1631,7 @@ impl App { self.group_detail = None; self.group_members.clear(); self.group_admins.clear(); + self.group_relays.clear(); self.selected_member = 0; self.status_message = None; @@ -1557,7 +1640,11 @@ impl App { account: account.clone(), group_id: group_id.clone(), }, - Effect::LoadGroupMembers { account, group_id }, + Effect::LoadGroupMembers { + account: account.clone(), + group_id: group_id.clone(), + }, + Effect::LoadGroupRelays { account, group_id }, ] } @@ -1572,27 +1659,24 @@ impl App { } } - /// Nudge viewport scroll by 1 if the selection moved outside the visible range. + /// Nudge viewport scroll if the selection moved outside the visible range. + /// + /// Uses `visible_msg_range` (updated each render) to know exactly which + /// messages are on screen. Only scrolls when the selection is truly off-screen. fn scroll_to_follow(&mut self, direction: isize) { let sel = match self.selected_msg_index() { Some(i) => i, None => return, }; let last = self.messages.len().saturating_sub(1); - let bottom_visible = last.saturating_sub(self.message_scroll); + let (vis_first, vis_last) = self.visible_msg_range; - if direction > 0 && sel > bottom_visible { - // Selection below viewport — scroll down by 1 + if direction > 0 && sel > vis_last { + // Selection moved below the visible range — scroll down self.message_scroll = self.message_scroll.saturating_sub(1); - } else if direction < 0 { - // Selection moved up — scroll up only if selection is no longer rendered. - // The viewport fits ~message_viewport_height rows. Assume each message - // averages 1 row (short messages). If selection is more than viewport_height - // messages above the bottom, it's likely off-screen. - let distance_from_bottom = bottom_visible.saturating_sub(sel); - if distance_from_bottom >= self.message_viewport_height { - self.message_scroll += 1; - } + } else if direction < 0 && sel < vis_first { + // Selection moved above the visible range — scroll up + self.message_scroll = (self.message_scroll + 1).min(last); } } @@ -1870,6 +1954,7 @@ impl App { self.active_group_id = Some(group_id.clone()); self.messages.clear(); + self.messages_loading = true; self.media_downloads.clear(); self.inline_images.clear(); self.reply_to = None; @@ -1894,6 +1979,7 @@ impl App { self.group_detail = None; self.group_members.clear(); self.group_admins.clear(); + self.group_relays.clear(); vec![] } KeyCode::Char('j') | KeyCode::Down => { @@ -2117,7 +2203,7 @@ impl App { }; self.popup = Some(Popup::Confirm { title: "Logout".into(), - message: "Log out of this account? (y/n)".into(), + message: "All local data for this account will be permanently deleted.".into(), purpose: ConfirmPurpose::Logout { account }, }); vec![] @@ -2139,6 +2225,43 @@ impl App { } } + // ── Health screen key handling ────────────────────────────────────── + + fn handle_health_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Esc => { + self.screen = Screen::Main; + self.relay_health = None; + vec![] + } + KeyCode::Tab => { + self.health_view = match self.health_view { + HealthView::ByStatus => HealthView::ByPlane, + HealthView::ByPlane => HealthView::ByStatus, + }; + self.health_scroll = 0; + vec![] + } + KeyCode::Char('j') | KeyCode::Down => { + self.health_scroll = self + .health_scroll + .saturating_add(1) + .min(self.health_max_scroll); + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + self.health_scroll = self.health_scroll.saturating_sub(1); + vec![] + } + KeyCode::Char('r') => { + self.health_error = None; + self.health_scroll = 0; + vec![Effect::LoadRelayHealth] + } + _ => vec![], + } + } + // ── User search key handling ────────────────────────────────────── fn handle_search_key(&mut self, key: KeyEvent) -> Vec { @@ -2194,6 +2317,7 @@ impl App { }; let query = self.search_input.value.clone(); self.clear_search_results(); + self.search_loading = true; vec![Effect::SearchUsers { account, query }] } else { vec![] @@ -2362,7 +2486,7 @@ impl App { .unwrap_or("this account"); self.popup = Some(Popup::Confirm { title: "Logout".into(), - message: format!("Log out of \"{name}\"? (y/n)"), + message: format!("Log out of \"{name}\"?\nAll local data will be permanently deleted."), purpose: ConfirmPurpose::Logout { account: account_id, }, @@ -2485,6 +2609,7 @@ impl App { Screen::Profile => crate::screen::profile::draw(self, frame, area), Screen::Settings => crate::screen::settings::draw(self, frame, area), Screen::UserSearch => crate::screen::user_search::draw(self, frame, area), + Screen::Health => crate::screen::health::draw(self, frame, area), } if show_global_logs { @@ -2511,16 +2636,18 @@ impl App { frame.render_widget(widget, area); } Popup::Confirm { title, message, .. } => { + let mut body: Vec = vec![Line::raw("")]; + for line in message.split('\n') { + body.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::Yellow), + ))); + } + let height = (body.len() as u16 + 5).max(8); let widget = PopupWidget::new(title) - .body(vec![ - Line::raw(""), - Line::from(Span::styled( - message.as_str(), - Style::default().fg(Color::Yellow), - )), - ]) + .body(body) .hints(vec![("y", "Yes"), ("n", "No")]) - .size(50, 8); + .size(55, height); frame.render_widget(widget, area); } Popup::Help { screen } => { @@ -3029,6 +3156,7 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("g", "Group info")); lines.push(hint("I", "View invites")); lines.push(hint("p", "Profile")); + lines.push(hint("h", "Relay health")); lines.push(hint("S", "Settings")); lines.push(hint("/", "Search users")); lines.push(hint("`", "Toggle logs")); @@ -3062,6 +3190,12 @@ fn help_lines(screen: &Screen) -> Vec> { Screen::Settings => { lines.push(hint("Esc", "Back")); } + Screen::Health => { + lines.push(hint("Tab", "Switch view (status / plane)")); + lines.push(hint("j / k", "Scroll")); + lines.push(hint("r", "Refresh")); + lines.push(hint("Esc", "Back")); + } Screen::Login => { lines.push(hint("c", "Create identity")); lines.push(hint("l", "Login with nsec")); @@ -4251,6 +4385,51 @@ mod tests { assert_eq!(app.message_scroll, 0, "should auto-scroll"); } + // ── scroll_to_follow ──────────────────────────────────────────── + + #[test] + fn scroll_to_follow_noop_when_selection_visible() { + let mut app = app_on_main(); + app.messages = (0..20) + .map(|i| json!({"id": format!("m{i}"), "content": format!("msg{i}"), "author": "a"})) + .collect(); + app.message_scroll = 0; // at bottom + // Simulate visible range: messages 10-19 are visible + app.visible_msg_range = (10, 19); + app.selected_message = Some(15); // within visible range + app.scroll_to_follow(1); + assert_eq!( + app.message_scroll, 0, + "should not scroll when selection is visible" + ); + } + + #[test] + fn scroll_to_follow_scrolls_up_when_above_visible() { + let mut app = app_on_main(); + app.messages = (0..20) + .map(|i| json!({"id": format!("m{i}"), "content": format!("msg{i}"), "author": "a"})) + .collect(); + app.message_scroll = 0; + app.visible_msg_range = (10, 19); + app.selected_message = Some(9); // above visible range + app.scroll_to_follow(-1); + assert_eq!(app.message_scroll, 1, "should scroll up by 1"); + } + + #[test] + fn scroll_to_follow_scrolls_down_when_below_visible() { + let mut app = app_on_main(); + app.messages = (0..20) + .map(|i| json!({"id": format!("m{i}"), "content": format!("msg{i}"), "author": "a"})) + .collect(); + app.message_scroll = 5; + app.visible_msg_range = (5, 14); + app.selected_message = Some(15); // below visible range + app.scroll_to_follow(1); + assert_eq!(app.message_scroll, 4, "should scroll down by 1"); + } + // ── Reactions ───────────────────────────────────────────────────── #[test] @@ -4819,6 +4998,12 @@ mod tests { assert!(app.messages.is_empty()); assert!(app.active_group_id.is_none()); assert!(effects.iter().any(|e| matches!(e, Effect::CheckAccounts))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::UnsubscribeMessages))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::UnsubscribeSearch))); } #[test] diff --git a/src/event.rs b/src/event.rs index 8194cd3..6e7ec36 100644 --- a/src/event.rs +++ b/src/event.rs @@ -11,8 +11,7 @@ pub enum Event { Key(KeyEvent), Paste(String), Tick, - #[allow(dead_code)] - Resize(u16, u16), + Resize, /// Async task results injected back into the event loop. Action(Action), } @@ -35,7 +34,7 @@ impl EventLoop { let event = match evt { CrosstermEvent::Key(key) => Event::Key(key), CrosstermEvent::Paste(text) => Event::Paste(text), - CrosstermEvent::Resize(w, h) => Event::Resize(w, h), + CrosstermEvent::Resize(..) => Event::Resize, _ => continue, }; if tx_term.send(event).is_err() { @@ -80,7 +79,7 @@ pub fn map_event(event: &Event) -> Option { Event::Key(key) => map_key_event(key), Event::Paste(text) => Some(Action::Paste(text.clone())), Event::Tick => Some(Action::Tick), - Event::Resize(_, _) => Some(Action::Render), + Event::Resize => Some(Action::Render), Event::Action(action) => Some(action.clone()), } } diff --git a/src/main.rs b/src/main.rs index aef1ca3..2ef4655 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,17 +10,34 @@ mod wn; use anyhow::Result; use tokio::process::Child; use tokio::sync::mpsc; +use tokio::task::JoinHandle; use action::{Action, Effect}; use app::App; use event::{map_event, Event, EventLoop}; -/// Tracks long-lived child processes for streaming commands. +/// Wrapper that kills a child process when dropped. +/// +/// Uses `start_kill()` (synchronous SIGKILL) so it's safe to call from `Drop`. +/// This ensures spawned CLI processes are cleaned up when their reader task is +/// aborted or completes. +struct KillOnDrop(Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.start_kill(); + } +} + +/// Tracks spawned reader tasks for streaming CLI commands. +/// +/// Aborting the task drops the `KillOnDrop` guard inside it, which kills the +/// underlying child process. struct StreamHandles { - chats: Option, - messages: Option, - notifications: Option, - search: Option, + chats: Option>, + messages: Option>, + notifications: Option>, + search: Option>, } impl StreamHandles { @@ -34,34 +51,26 @@ impl StreamHandles { } fn kill_messages(&mut self) { - if let Some(mut child) = self.messages.take() { - tokio::spawn(async move { - let _ = child.kill().await; - }); + if let Some(handle) = self.messages.take() { + handle.abort(); } } fn kill_chats(&mut self) { - if let Some(mut child) = self.chats.take() { - tokio::spawn(async move { - let _ = child.kill().await; - }); + if let Some(handle) = self.chats.take() { + handle.abort(); } } fn kill_notifications(&mut self) { - if let Some(mut child) = self.notifications.take() { - tokio::spawn(async move { - let _ = child.kill().await; - }); + if let Some(handle) = self.notifications.take() { + handle.abort(); } } fn kill_search(&mut self) { - if let Some(mut child) = self.search.take() { - tokio::spawn(async move { - let _ = child.kill().await; - }); + if let Some(handle) = self.search.take() { + handle.abort(); } } @@ -173,6 +182,8 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::DeleteMessage { .. } => "DeleteMessage", Effect::LoadGroupDetail { .. } => "LoadGroupDetail", Effect::LoadGroupMembers { .. } => "LoadGroupMembers", + Effect::LoadGroupRelays { .. } => "LoadGroupRelays", + Effect::LoadAccountRelays { .. } => "LoadAccountRelays", Effect::LoadInvites { .. } => "LoadInvites", Effect::CreateGroup { .. } => "CreateGroup", Effect::AddMember { .. } => "AddMember", @@ -186,7 +197,6 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::ExportNsec { .. } => "ExportNsec", Effect::FetchProfileImage { .. } => "FetchProfileImage", Effect::LoadSettings { .. } => "LoadSettings", - Effect::UpdateSetting { .. } => "UpdateSetting", Effect::LoadFollows { .. } => "LoadFollows", Effect::FollowUser { .. } => "FollowUser", Effect::UnfollowUser { .. } => "UnfollowUser", @@ -199,6 +209,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadMediaImage { .. } => "LoadMediaImage", Effect::LoadMediaPopup { .. } => "LoadMediaPopup", Effect::UploadMedia { .. } => "UploadMedia", + Effect::LoadRelayHealth => "LoadRelayHealth", Effect::TailDaemonLog => "TailDaemonLog", }; send_log(tx, format!("Effect: {effect_name}")); @@ -269,10 +280,10 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::SubscribeNotifications => { streams.kill_notifications(); let tx = tx.clone(); - tokio::spawn(async move { + streams.notifications = Some(tokio::spawn(async move { match wn::stream(&["notifications", "subscribe"]).await { Ok((child, mut rx)) => { - drop(child); + let _child = KillOnDrop(child); while let Some(val) = rx.recv().await { let notif = val .get("result") @@ -294,17 +305,17 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m // Notifications are non-critical — silently fail } } - }); + })); } Effect::SubscribeChats { account } => { streams.kill_chats(); let tx = tx.clone(); - tokio::spawn(async move { + streams.chats = Some(tokio::spawn(async move { match wn::stream(&["--account", &account, "chats", "subscribe"]).await { Ok((child, mut rx)) => { + let _child = KillOnDrop(child); send_log(&tx, "Chats stream connected"); - drop(child); while let Some(val) = rx.recv().await { // Stream sends {"result": {"item": {...}, "trigger": "..."}} // Extract the inner item (the actual chat object) @@ -329,21 +340,21 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m )))); } } - }); + })); } Effect::SubscribeMessages { account, group_id } => { streams.kill_messages(); let tx = tx.clone(); - tokio::spawn(async move { + streams.messages = Some(tokio::spawn(async move { match wn::stream(&["--account", &account, "messages", "subscribe", &group_id]).await { Ok((child, mut rx)) => { + let _child = KillOnDrop(child); send_log( &tx, format!("Messages stream connected (group: {group_id})"), ); - drop(child); let gid = group_id.clone(); while let Some(val) = rx.recv().await { // Messages come as {"result": {"message": {...}, "trigger": "..."}} @@ -368,7 +379,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m )))); } } - }); + })); } Effect::UnsubscribeMessages => { @@ -503,6 +514,32 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::LoadGroupRelays { account, group_id } => { + let tx = tx.clone(); + tokio::spawn(async move { + let relays = + match wn::exec(&["--account", &account, "groups", "relays", &group_id]).await { + Ok(serde_json::Value::Array(arr)) => arr + .into_iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + _ => vec![], + }; + let _ = tx.send(Event::Action(Action::GroupRelaysLoaded(relays))); + }); + } + + Effect::LoadAccountRelays { account } => { + let tx = tx.clone(); + tokio::spawn(async move { + let relays = match wn::exec(&["--account", &account, "relays", "list"]).await { + Ok(serde_json::Value::Array(arr)) => arr, + _ => vec![], + }; + let _ = tx.send(Event::Action(Action::AccountRelaysLoaded(relays))); + }); + } + Effect::LoadGroupMembers { account, group_id } => { let tx = tx.clone(); tokio::spawn(async move { @@ -835,22 +872,6 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } - Effect::UpdateSetting { - account, - key, - value, - } => { - let tx = tx.clone(); - tokio::spawn(async move { - let action = - match wn::exec(&["--account", &account, "settings", &key, &value]).await { - Ok(_) => Action::SettingsUpdateSuccess(format!("{key} updated")), - Err(e) => Action::SettingsUpdateError(e.to_string()), - }; - let _ = tx.send(Event::Action(action)); - }); - } - Effect::LoadFollows { account } => { let tx = tx.clone(); tokio::spawn(async move { @@ -914,10 +935,10 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::SearchUsers { account, query } => { streams.kill_search(); let tx = tx.clone(); - tokio::spawn(async move { + streams.search = Some(tokio::spawn(async move { match wn::stream(&["--account", &account, "users", "search", &query]).await { Ok((child, mut rx)) => { - drop(child); + let _child = KillOnDrop(child); while let Some(val) = rx.recv().await { // Search results come as: // {"result": {"new_results": [...users...], "trigger": "..."}} @@ -943,7 +964,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m )))); } } - }); + })); } Effect::UnsubscribeSearch => { @@ -1085,6 +1106,17 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::LoadRelayHealth => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["debug", "relay-control-state"]).await { + Ok(val) => Action::RelayHealthLoaded(val), + Err(e) => Action::RelayHealthError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::TailDaemonLog => { let tx = tx.clone(); tokio::spawn(async move { diff --git a/src/screen/group_detail.rs b/src/screen/group_detail.rs index f632d5f..d77cf54 100644 --- a/src/screen/group_detail.rs +++ b/src/screen/group_detail.rs @@ -91,24 +91,35 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { let detail = app.group_detail.as_ref().unwrap(); - // Layout: info section + members list + hints + // Layout: info section + relays + members list + hints + let relay_height = if app.group_relays.is_empty() { + 0 + } else { + app.group_relays.len() as u16 + 2 // header + relays + blank line + }; let vertical = Layout::vertical([ - Constraint::Length(5), // Group info - Constraint::Length(1), // Separator - Constraint::Fill(1), // Members list - Constraint::Length(2), // Hints + Constraint::Length(5), // Group info + Constraint::Length(relay_height), // Relays (0 if empty) + Constraint::Length(1), // Separator + Constraint::Fill(1), // Members list + Constraint::Length(2), // Hints ]) .split(inner); // Group info draw_info(detail, &app.group_members, frame, vertical[0]); + // Relays + if !app.group_relays.is_empty() { + draw_relays(&app.group_relays, frame, vertical[1]); + } + // Separator let sep = Paragraph::new(Span::styled( "─── Members ───", Style::default().fg(Color::DarkGray), )); - frame.render_widget(sep, vertical[1]); + frame.render_widget(sep, vertical[2]); // Members list draw_members( @@ -116,11 +127,11 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { &app.group_admins, app.selected_member, frame, - vertical[2], + vertical[3], ); // Hints - draw_hints(frame, vertical[3]); + draw_hints(frame, vertical[4]); } fn draw_info(detail: &Value, members: &[Value], frame: &mut Frame, area: Rect) { @@ -160,6 +171,20 @@ fn draw_info(detail: &Value, members: &[Value], frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(lines), area); } +fn draw_relays(relays: &[String], frame: &mut Frame, area: Rect) { + let mut lines = vec![Line::from(Span::styled( + " Relays:", + Style::default().fg(Color::DarkGray), + ))]; + for url in relays { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(url, Style::default().fg(Color::White)), + ])); + } + frame.render_widget(Paragraph::new(lines), area); +} + fn draw_members( members: &[Value], admins: &[Value], diff --git a/src/screen/health.rs b/src/screen/health.rs new file mode 100644 index 0000000..a7b440b --- /dev/null +++ b/src/screen/health.rs @@ -0,0 +1,734 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use serde_json::Value; + +use crate::app::{App, HealthView}; + +// ── Data extraction ────────────────────────────────────────────────── + +struct RelayEntry { + url: String, + status: String, +} + +fn status_order(status: &str) -> u8 { + match status { + "Disconnected" => 0, + "Pending" => 1, + "Connecting" => 2, + "Connected" => 3, + _ => 4, + } +} + +fn status_color(status: &str) -> Color { + match status { + "Connected" => Color::Green, + "Connecting" => Color::Yellow, + "Pending" => Color::Yellow, + "Disconnected" => Color::Red, + _ => Color::DarkGray, + } +} + +fn status_icon(status: &str) -> &'static str { + match status { + "Connected" => "●", + "Connecting" => "◐", + "Pending" => "○", + "Disconnected" => "✕", + _ => "?", + } +} + +fn collect_from_session( + session: &Value, + section: &'static str, + relays: &mut Vec<(String, String, &'static str)>, +) { + let Some(arr) = session.get("relays").and_then(|v| v.as_array()) else { + return; + }; + for relay in arr { + let url = relay + .get("relay_url") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let status = relay + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + relays.push((url.to_string(), status.to_string(), section)); + } +} + +/// Extract all relays, deduplicating by URL, tracking sections, keeping worst status. +fn extract_relays(val: &Value) -> Vec { + let mut raw: Vec<(String, String, &'static str)> = Vec::new(); + + if let Some(accounts) = val + .get("account_inbox") + .and_then(|v| v.get("accounts")) + .and_then(|v| v.as_array()) + { + for acct in accounts { + if let Some(session) = acct.get("session") { + collect_from_session(session, "inbox", &mut raw); + } + } + } + + if let Some(session) = val.get("discovery").and_then(|v| v.get("session")) { + collect_from_session(session, "discovery", &mut raw); + } + + if let Some(session) = val.get("group").and_then(|v| v.get("session")) { + collect_from_session(session, "group", &mut raw); + } + + if let Some(eph) = val.get("ephemeral") { + if let Some(accounts) = eph.get("accounts").and_then(|v| v.as_array()) { + for acct in accounts { + if let Some(session) = acct.get("session") { + collect_from_session(session, "ephemeral", &mut raw); + } + } + } + if let Some(session) = eph.get("anonymous").and_then(|v| v.get("session")) { + collect_from_session(session, "ephemeral", &mut raw); + } + } + + let mut map: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (url, status, _section) in raw { + let entry = map.entry(url).or_insert_with(|| status.clone()); + // Keep the worst (lowest order) status + if status_order(&status) < status_order(entry) { + *entry = status; + } + } + + let mut relays: Vec = map + .into_iter() + .map(|(url, status)| RelayEntry { url, status }) + .collect(); + + relays.sort_by(|a, b| { + status_order(&a.status) + .cmp(&status_order(&b.status)) + .then(a.url.cmp(&b.url)) + }); + + relays +} + +/// Extract relays for a single plane, preserving per-relay status. +fn extract_plane_relays(val: &Value, plane: &str) -> Vec<(String, String)> { + let mut relays = Vec::new(); + let collect = |session: &Value, out: &mut Vec<(String, String)>| { + if let Some(arr) = session.get("relays").and_then(|v| v.as_array()) { + for r in arr { + let url = r.get("relay_url").and_then(|v| v.as_str()).unwrap_or("?"); + let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + match out.iter_mut().find(|(u, _)| u == url) { + Some((_, existing)) if status_order(status) < status_order(existing) => { + *existing = status.to_string(); + } + Some(_) => {} + None => out.push((url.to_string(), status.to_string())), + } + } + } + }; + + match plane { + "inbox" => { + if let Some(accounts) = val + .get("account_inbox") + .and_then(|v| v.get("accounts")) + .and_then(|v| v.as_array()) + { + for acct in accounts { + if let Some(session) = acct.get("session") { + collect(session, &mut relays); + } + } + } + } + "discovery" => { + if let Some(session) = val.get("discovery").and_then(|v| v.get("session")) { + collect(session, &mut relays); + } + } + "group" => { + if let Some(session) = val.get("group").and_then(|v| v.get("session")) { + collect(session, &mut relays); + } + } + "ephemeral" => { + if let Some(eph) = val.get("ephemeral") { + if let Some(accounts) = eph.get("accounts").and_then(|v| v.as_array()) { + for acct in accounts { + if let Some(session) = acct.get("session") { + collect(session, &mut relays); + } + } + } + if let Some(session) = eph.get("anonymous").and_then(|v| v.get("session")) { + collect(session, &mut relays); + } + } + } + _ => {} + } + + relays.sort_by(|a, b| { + status_order(&a.1) + .cmp(&status_order(&b.1)) + .then(a.0.cmp(&b.0)) + }); + relays +} + +fn get_u64(val: &Value, path: &[&str]) -> u64 { + let mut v = val; + for key in path { + match v.get(key) { + Some(next) => v = next, + None => return 0, + } + } + v.as_u64().unwrap_or(0) +} + +// ── Rendering helpers ──────────────────────────────────────────────── + +fn status_counts_line(relays: &[RelayEntry]) -> Line<'static> { + let count = |s: &str| relays.iter().filter(|r| r.status == s).count(); + let connected = count("Connected"); + let connecting = count("Connecting"); + let pending = count("Pending"); + let disconnected = count("Disconnected"); + + let mut spans = vec![Span::raw(" ")]; + if connected > 0 { + spans.push(Span::styled( + format!("● {connected} connected"), + Style::default().fg(Color::Green), + )); + spans.push(Span::raw(" ")); + } + if connecting > 0 { + spans.push(Span::styled( + format!("◐ {connecting} connecting"), + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(" ")); + } + if pending > 0 { + spans.push(Span::styled( + format!("○ {pending} pending"), + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(" ")); + } + if disconnected > 0 { + spans.push(Span::styled( + format!("✕ {disconnected} disconnected"), + Style::default().fg(Color::Red), + )); + } + Line::from(spans) +} + +fn summary_line(val: &Value, relays: &[RelayEntry]) -> Line<'static> { + let accounts = get_u64(val, &["account_inbox", "active_account_count"]); + let groups = get_u64(val, &["group", "group_count"]); + let watched = get_u64(val, &["discovery", "watched_user_count"]); + let connected = relays.iter().filter(|r| r.status == "Connected").count(); + let total = relays.len(); + let dim = Style::default().fg(Color::DarkGray); + + Line::from(vec![ + Span::styled(format!(" {connected}/{total} relays connected"), dim), + Span::styled(" | ", dim), + Span::styled(format!("{accounts} accounts"), dim), + Span::styled(" ", dim), + Span::styled(format!("{groups} groups"), dim), + Span::styled(" ", dim), + Span::styled(format!("{watched} watched users"), dim), + ]) +} + +fn section_header(label: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" {label}"), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) +} + +/// Lay out relay entries in newspaper-style columns (fill down, then across). +/// Each entry shows: icon + URL, padded to uniform width per column. +fn columnize_relays( + relays: &[(impl AsRef, impl AsRef)], + width: u16, +) -> Vec> { + if relays.is_empty() { + return vec![]; + } + + let max_url = relays + .iter() + .map(|(u, _)| u.as_ref().len()) + .max() + .unwrap_or(20); + // Each cell: indent(4) + icon(2) + url(max_url) + gap(2) + let col_width = max_url + 8; + let num_cols = ((width as usize) / col_width).max(1); + let num_rows = relays.len().div_ceil(num_cols); + + let mut lines = Vec::with_capacity(num_rows); + for row in 0..num_rows { + let mut spans: Vec = Vec::new(); + for col in 0..num_cols { + let idx = col * num_rows + row; + if idx < relays.len() { + let url = relays[idx].0.as_ref(); + let status = relays[idx].1.as_ref(); + let icon = status_icon(status); + let color = status_color(status); + spans.push(Span::styled( + format!(" {icon} "), + Style::default().fg(color), + )); + spans.push(Span::styled( + format!("{: Vec> { + let mut lines: Vec = Vec::new(); + + // Group relays by status + let mut current_status: Option<&str> = None; + let mut section_batch: Vec<(&str, &str)> = Vec::new(); + + for relay in relays { + if current_status != Some(&relay.status) { + // Flush previous batch + if !section_batch.is_empty() { + lines.extend(columnize_relays(§ion_batch, width)); + section_batch.clear(); + } + if current_status.is_some() { + lines.push(Line::raw("")); + } + lines.push(section_header(&relay.status)); + current_status = Some(&relay.status); + } + section_batch.push((&relay.url, &relay.status)); + } + // Flush last batch + if !section_batch.is_empty() { + lines.extend(columnize_relays(§ion_batch, width)); + } + + lines +} + +fn build_by_plane(val: &Value, width: u16) -> Vec> { + let dim = Style::default().fg(Color::DarkGray); + let mut lines: Vec = Vec::new(); + + // ── Inbox + let inbox_relays = extract_plane_relays(val, "inbox"); + let inbox_accounts = get_u64(val, &["account_inbox", "active_account_count"]); + let inbox_subs: u64 = val + .get("account_inbox") + .and_then(|v| v.get("accounts")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| { + a.get("session") + .and_then(|s| s.get("registered_subscription_count")) + .and_then(|v| v.as_u64()) + }) + .sum() + }) + .unwrap_or(0); + let inbox_connected = inbox_relays + .iter() + .filter(|(_, s)| s == "Connected") + .count(); + lines.push(section_header("Account Inbox")); + lines.push(Line::from(Span::styled( + format!( + " {inbox_accounts} accounts, {}/{} relays, {inbox_subs} subscriptions", + inbox_connected, + inbox_relays.len() + ), + dim, + ))); + lines.extend(columnize_relays(&inbox_relays, width)); + + // ── Discovery + lines.push(Line::raw("")); + let disc_relays = extract_plane_relays(val, "discovery"); + let disc_subs = get_u64( + val, + &["discovery", "session", "registered_subscription_count"], + ); + let disc_watched = get_u64(val, &["discovery", "watched_user_count"]); + let disc_follows = get_u64(val, &["discovery", "follow_list_subscription_count"]); + let disc_connected = disc_relays.iter().filter(|(_, s)| s == "Connected").count(); + lines.push(section_header("Discovery")); + lines.push(Line::from(Span::styled( + format!( + " {}/{} relays, {disc_subs} subs, {disc_follows} follow lists, {disc_watched} watched users", + disc_connected, + disc_relays.len() + ), + dim, + ))); + lines.extend(columnize_relays(&disc_relays, width)); + + // ── Group + lines.push(Line::raw("")); + let group_relays = extract_plane_relays(val, "group"); + let group_count = get_u64(val, &["group", "group_count"]); + let group_subs = get_u64(val, &["group", "session", "registered_subscription_count"]); + let group_contexts = get_u64(val, &["group", "session", "router_context_count"]); + let group_connected = group_relays + .iter() + .filter(|(_, s)| s == "Connected") + .count(); + lines.push(section_header("Group")); + lines.push(Line::from(Span::styled( + format!( + " {group_count} groups, {}/{} relays, {group_subs} subs, {group_contexts} router contexts", + group_connected, + group_relays.len() + ), + dim, + ))); + lines.extend(columnize_relays(&group_relays, width)); + + // ── Ephemeral + lines.push(Line::raw("")); + let eph_relays = extract_plane_relays(val, "ephemeral"); + let eph_scopes = get_u64(val, &["ephemeral", "account_scope_count"]); + let eph_ad_hoc = val + .get("ephemeral") + .and_then(|v| v.get("anonymous")) + .and_then(|v| v.get("ad_hoc_relay_count")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let eph_pinned = val + .get("ephemeral") + .and_then(|v| v.get("anonymous")) + .and_then(|v| v.get("pinned_relay_count")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let eph_connected = eph_relays.iter().filter(|(_, s)| s == "Connected").count(); + lines.push(section_header("Ephemeral")); + lines.push(Line::from(Span::styled( + format!( + " {eph_scopes} scopes, {}/{} relays ({eph_pinned} pinned, {eph_ad_hoc} ad-hoc)", + eph_connected, + eph_relays.len() + ), + dim, + ))); + lines.extend(columnize_relays(&eph_relays, width)); + + lines +} + +// ── Main draw ──────────────────────────────────────────────────────── + +pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { + let outer = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Relay Health "); + let inner = outer.inner(area); + frame.render_widget(outer, area); + + if app.relay_health.is_none() { + let (text, color) = match &app.health_error { + Some(err) => (format!("Failed: {err} (r to retry)"), Color::Red), + None => ("Loading relay state...".into(), Color::Yellow), + }; + let msg = Paragraph::new(Span::styled(text, Style::default().fg(color))).centered(); + let centered = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + frame.render_widget(msg, centered[1]); + return; + } + + let val = app.relay_health.as_ref().unwrap(); + let relays = extract_relays(val); + + let vertical = Layout::vertical([ + Constraint::Length(3), // Header + Constraint::Fill(1), // Content + Constraint::Length(1), // Hints + ]) + .split(inner); + + // Header + let header = vec![ + status_counts_line(&relays), + summary_line(val, &relays), + Line::raw(""), + ]; + frame.render_widget(Paragraph::new(header), vertical[0]); + + // Content (width-aware for multi-column) + let content_width = vertical[1].width; + let content_lines = match app.health_view { + HealthView::ByStatus => build_by_status(&relays, content_width), + HealthView::ByPlane => build_by_plane(val, content_width), + }; + let viewport_height = vertical[1].height as usize; + app.health_max_scroll = content_lines.len().saturating_sub(viewport_height); + app.health_scroll = app.health_scroll.min(app.health_max_scroll); + let scroll = app.health_scroll as u16; + frame.render_widget( + Paragraph::new(content_lines).scroll((scroll, 0)), + vertical[1], + ); + + // Hints + let (active_label, inactive_label) = match app.health_view { + HealthView::ByStatus => ("By Status", "By Plane"), + HealthView::ByPlane => ("By Plane", "By Status"), + }; + let hints = Line::from(vec![ + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), + Span::styled( + format!(" {active_label} "), + Style::default().fg(Color::Black).bg(Color::DarkGray), + ), + Span::styled( + format!(" {inactive_label}"), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled("[r] ", Style::default().fg(Color::Cyan)), + Span::raw("Refresh "), + Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), + Span::raw("Scroll "), + Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), + Span::raw("Back"), + ]); + frame.render_widget(Paragraph::new(hints), vertical[2]); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_relays_deduplicates_across_sections() { + let val = json!({ + "account_inbox": { + "accounts": [{ + "session": { + "relays": [ + {"relay_url": "wss://nos.lol", "status": "Connected"}, + {"relay_url": "wss://relay.damus.io", "status": "Connected"}, + ] + } + }], + "active_account_count": 1 + }, + "discovery": { + "session": { + "relays": [ + {"relay_url": "wss://nos.lol", "status": "Connected"}, + ] + }, + "watched_user_count": 10 + }, + "group": { + "session": { + "relays": [ + {"relay_url": "wss://nos.lol", "status": "Connected"}, + {"relay_url": "wss://bad.relay", "status": "Disconnected"}, + ] + }, + "group_count": 5 + } + }); + + let relays = extract_relays(&val); + // 3 unique URLs: nos.lol, relay.damus.io, bad.relay + assert_eq!(relays.len(), 3); + // Disconnected sorts first + assert_eq!(relays[0].url, "wss://bad.relay"); + assert_eq!(relays[0].status, "Disconnected"); + // nos.lol appears in all 3 sections but only once in the output + assert_eq!( + relays.iter().filter(|r| r.url == "wss://nos.lol").count(), + 1 + ); + } + + #[test] + fn extract_relays_keeps_worst_status() { + let val = json!({ + "account_inbox": { + "accounts": [{ + "session": { + "relays": [ + {"relay_url": "wss://flaky.relay", "status": "Connected"}, + ] + } + }], + "active_account_count": 1 + }, + "group": { + "session": { + "relays": [ + {"relay_url": "wss://flaky.relay", "status": "Disconnected"}, + ] + }, + "group_count": 1 + } + }); + + let relays = extract_relays(&val); + assert_eq!(relays.len(), 1); + assert_eq!(relays[0].status, "Disconnected"); + } + + #[test] + fn extract_relays_empty_state() { + let relays = extract_relays(&json!({})); + assert!(relays.is_empty()); + } + + #[test] + fn extract_plane_relays_returns_section_relays() { + let val = json!({ + "discovery": { + "session": { + "relays": [ + {"relay_url": "wss://a.com", "status": "Connected"}, + {"relay_url": "wss://b.com", "status": "Disconnected"}, + ] + } + } + }); + let relays = extract_plane_relays(&val, "discovery"); + assert_eq!(relays.len(), 2); + assert_eq!(relays[0].0, "wss://b.com"); // Disconnected sorts first + } + + #[test] + fn columnize_single_column_narrow() { + let relays = vec![ + ("wss://a.com".to_string(), "Connected".to_string()), + ("wss://b.com".to_string(), "Disconnected".to_string()), + ]; + let lines = columnize_relays(&relays, 30); + assert_eq!(lines.len(), 2); // One per relay, single column + } + + #[test] + fn columnize_multiple_columns_wide() { + let relays: Vec<_> = (0..6) + .map(|i| { + ( + format!("wss://relay{i}.example.com"), + "Connected".to_string(), + ) + }) + .collect(); + // "wss://relay0.example.com" = 24 chars, col_width = 24+8 = 32 + // Width 100: 100/32 = 3 cols, ceil(6/3) = 2 rows + let lines = columnize_relays(&relays, 100); + assert_eq!(lines.len(), 2); + // Width 40: 40/32 = 1 col, 6 rows + let lines = columnize_relays(&relays, 40); + assert_eq!(lines.len(), 6); + } + + #[test] + fn columnize_empty() { + let relays: Vec<(String, String)> = vec![]; + let lines = columnize_relays(&relays, 100); + assert!(lines.is_empty()); + } + + #[test] + fn build_by_plane_produces_four_sections() { + let val = json!({ + "account_inbox": { + "accounts": [{ + "session": { + "registered_subscription_count": 1, + "relays": [{"relay_url": "wss://r.com", "status": "Connected"}] + } + }], + "active_account_count": 1 + }, + "discovery": { + "session": { + "registered_subscription_count": 2, + "relays": [{"relay_url": "wss://r.com", "status": "Connected"}] + }, + "follow_list_subscription_count": 1, + "watched_user_count": 5 + }, + "group": { + "session": { + "registered_subscription_count": 3, + "router_context_count": 10, + "relays": [{"relay_url": "wss://r.com", "status": "Connected"}] + }, + "group_count": 2 + }, + "ephemeral": { + "account_scope_count": 1, + "accounts": [], + "anonymous": { + "ad_hoc_relay_count": 5, + "pinned_relay_count": 2, + "session": { + "relays": [{"relay_url": "wss://r.com", "status": "Connected"}] + } + } + } + }); + let lines = build_by_plane(&val, 80); + let text: Vec = lines.iter().map(|l| l.to_string()).collect(); + assert!(text.iter().any(|l| l.contains("Account Inbox"))); + assert!(text.iter().any(|l| l.contains("Discovery"))); + assert!(text.iter().any(|l| l.contains("Group"))); + assert!(text.iter().any(|l| l.contains("Ephemeral"))); + assert!(text.iter().any(|l| l.contains("5 watched users"))); + } +} diff --git a/src/screen/main_screen.rs b/src/screen/main_screen.rs index 2c7140c..6477931 100644 --- a/src/screen/main_screen.rs +++ b/src/screen/main_screen.rs @@ -59,6 +59,7 @@ fn draw_chat_list(app: &App, frame: &mut Frame, area: Rect) { let widget = ChatListWidget::new(&app.chats, app.selected_chat) .focused(focused) + .loading(app.chats_loading) .unread(&app.unread_counts) .block(block); @@ -151,8 +152,10 @@ fn draw_messages(app: &mut App, frame: &mut Frame, area: Rect) { .block(block) .my_pubkey(app.account.as_deref()) .selected(selected) + .loading(app.messages_loading) .media_downloads(&app.media_downloads) .inline_images(&app.inline_images); + app.visible_msg_range = widget.visible_range(area); let image_rects = widget.image_positions(area); frame.render_widget(widget, area); @@ -235,6 +238,9 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { key("p"), label("Profile"), sep(), + key("h"), + label("Health"), + sep(), key("S"), label("Settings"), sep(), diff --git a/src/screen/mod.rs b/src/screen/mod.rs index 70e8c5b..0199c0d 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,4 +1,5 @@ pub mod group_detail; +pub mod health; pub mod login; pub mod main_screen; pub mod profile; @@ -14,4 +15,5 @@ pub enum Screen { Profile, Settings, UserSearch, + Health, } diff --git a/src/screen/profile.rs b/src/screen/profile.rs index 2f789e5..032c471 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -7,8 +7,26 @@ use ratatui::{ }; use ratatui_image::StatefulImage; +use serde_json::Value; + use crate::app::{hex_to_npub, App}; +fn draw_relays(relays: &[Value], frame: &mut Frame, area: Rect) { + let label = Style::default().fg(Color::DarkGray); + let mut lines = vec![Line::from(Span::styled(" Relays:", label))]; + for relay in relays { + let url = relay + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(url, Style::default().fg(Color::White)), + ])); + } + frame.render_widget(Paragraph::new(lines), area); +} + /// Format a profile field for display, showing "(not set)" when empty. fn display<'a>(val: &'a str, fallback: &'a str) -> &'a str { if val.is_empty() { @@ -53,9 +71,15 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { let has_image = app.profile_image.is_some(); let info_height = 9; + let relay_height = if app.account_relays.is_empty() { + 0 + } else { + app.account_relays.len() as u16 + 2 // header + relays + blank line + }; let vertical = Layout::vertical([ Constraint::Length(info_height), // Profile info (with optional image) + Constraint::Length(relay_height), // Relays (0 if empty) Constraint::Length(1), // Follows header Constraint::Fill(1), // Follows list Constraint::Length(2), // Hints (2 rows) @@ -140,6 +164,11 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { ]; frame.render_widget(Paragraph::new(lines), text_area); + // Relays + if !app.account_relays.is_empty() { + draw_relays(&app.account_relays, frame, vertical[1]); + } + // Follows header let follows_header = Line::from(vec![Span::styled( format!(" Following ({})", app.follows.len()), @@ -147,15 +176,17 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { .fg(Color::White) .add_modifier(Modifier::BOLD), )]); - frame.render_widget(Paragraph::new(follows_header), vertical[1]); + frame.render_widget(Paragraph::new(follows_header), vertical[2]); // Follows list if app.follows.is_empty() { - let empty = Paragraph::new(Span::styled( - " Not following anyone", - Style::default().fg(Color::DarkGray), - )); - frame.render_widget(empty, vertical[2]); + let (text, color) = if app.follows_loading { + (" Loading follows...", Color::Yellow) + } else { + (" Not following anyone", Color::DarkGray) + }; + let empty = Paragraph::new(Span::styled(text, Style::default().fg(color))); + frame.render_widget(empty, vertical[3]); } else { let items: Vec = app .follows @@ -197,12 +228,12 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { let list = List::new(items); let mut state = ListState::default(); state.select(Some(app.selected_follow)); - StatefulWidget::render(list, vertical[2], frame.buffer_mut(), &mut state); + StatefulWidget::render(list, vertical[3], frame.buffer_mut(), &mut state); } // Hints (2 rows) let hint_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(vertical[3]); + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(vertical[4]); let line1 = Line::from(vec![ Span::styled(" [n] ", Style::default().fg(Color::Cyan)), diff --git a/src/screen/user_search.rs b/src/screen/user_search.rs index be2dcd4..1488ad6 100644 --- a/src/screen/user_search.rs +++ b/src/screen/user_search.rs @@ -40,15 +40,19 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { .render(vertical[0], frame.buffer_mut()); // Result count - let count_text = if app.search_results.is_empty() { + let count_text = if app.search_loading { + " Searching...".to_string() + } else if app.search_results.is_empty() { " Type a query and press Enter to search".to_string() } else { format!(" {} result(s)", app.search_results.len()) }; - let count = Paragraph::new(Span::styled( - count_text, - Style::default().fg(Color::DarkGray), - )); + let count_color = if app.search_loading { + Color::Yellow + } else { + Color::DarkGray + }; + let count = Paragraph::new(Span::styled(count_text, Style::default().fg(count_color))); frame.render_widget(count, vertical[1]); // Results list diff --git a/src/widget/chat_list.rs b/src/widget/chat_list.rs index dc113c7..9a81962 100644 --- a/src/widget/chat_list.rs +++ b/src/widget/chat_list.rs @@ -5,7 +5,7 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, List, ListItem, ListState, StatefulWidget, Widget}, + widgets::{Block, List, ListItem, ListState, Paragraph, StatefulWidget, Widget}, }; use serde_json::Value; @@ -42,6 +42,7 @@ pub struct ChatListWidget<'a> { chats: &'a [Value], selected: usize, focused: bool, + loading: bool, block: Option>, unread: &'a HashMap, } @@ -52,11 +53,17 @@ impl<'a> ChatListWidget<'a> { chats, selected, focused: false, + loading: false, block: None, unread: &EMPTY_MAP, } } + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + pub fn unread(mut self, unread: &'a HashMap) -> Self { self.unread = unread; self @@ -86,6 +93,20 @@ impl Widget for ChatListWidget<'_> { .unwrap_or_default() .border_style(Style::default().fg(border_color)); + if self.chats.is_empty() { + let inner = block.inner(area); + block.render(area, buf); + if self.loading { + let text = Paragraph::new(Span::styled( + "Loading...", + Style::default().fg(Color::Yellow), + )) + .centered(); + text.render(inner, buf); + } + return; + } + let items: Vec = self .chats .iter() diff --git a/src/widget/message_list.rs b/src/widget/message_list.rs index e0a5f2e..4f84f66 100644 --- a/src/widget/message_list.rs +++ b/src/widget/message_list.rs @@ -373,6 +373,7 @@ pub struct MessageListWidget<'a> { my_pubkey: Option<&'a str>, /// If set, highlight this message index (absolute index into messages slice). selected: Option, + loading: bool, media_downloads: Option<&'a HashMap>, inline_images: Option<&'a HashMap>, } @@ -385,6 +386,7 @@ impl<'a> MessageListWidget<'a> { block: None, my_pubkey: None, selected: None, + loading: false, media_downloads: None, inline_images: None, } @@ -405,6 +407,11 @@ impl<'a> MessageListWidget<'a> { self } + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + pub fn media_downloads(mut self, downloads: &'a HashMap) -> Self { self.media_downloads = Some(downloads); self @@ -418,7 +425,11 @@ impl<'a> MessageListWidget<'a> { impl MessageListWidget<'_> { /// Compute which messages are visible and their Y positions. - /// Returns (visible_msg_indices, inner_area). + /// Returns (visible_msg_indices_with_y, inner_area). + /// + /// `scroll_from_bottom` determines the anchor: the newest message that should + /// be visible. We then fill backward from the anchor to find how many older + /// messages fit, and render forward from that start index to fill the viewport. fn compute_visible(&self, area: Rect) -> (Vec<(usize, usize)>, Rect) { let inner = if let Some(block) = &self.block { block.inner(area) @@ -433,34 +444,50 @@ impl MessageListWidget<'_> { let visible_height = inner.height as usize; let width = inner.width as usize; let total = self.messages.len(); - let skip_messages = self.scroll_from_bottom.min(total); + let skip = self.scroll_from_bottom.min(total.saturating_sub(1)); - let mut visible_msgs: Vec = Vec::new(); - let mut used_rows = 0; + // The anchor is the newest message that should be on screen. + let anchor = total.saturating_sub(1 + skip); - let end = total.saturating_sub(skip_messages); - for i in (0..end).rev() { + // Walk backward from the anchor to find the topmost message that fits. + let mut start = anchor; + let mut used_rows = + message_height_with_images(&self.messages[anchor], width, self.inline_images); + for i in (0..anchor).rev() { let h = message_height_with_images(&self.messages[i], width, self.inline_images); if used_rows + h > visible_height { break; } used_rows += h; - visible_msgs.push(i); + start = i; } - visible_msgs.reverse(); - // Compute Y positions + // Render forward from start, filling the viewport top-down. let mut result = Vec::new(); let mut y = inner.y as usize; - for idx in visible_msgs { - result.push((idx, y)); - let h = message_height_with_images(&self.messages[idx], width, self.inline_images); + let y_limit = inner.y as usize + visible_height; + for i in start..total { + let h = message_height_with_images(&self.messages[i], width, self.inline_images); + if !result.is_empty() && y + h > y_limit { + break; + } + result.push((i, y)); y += h; } (result, inner) } + /// Return the (first, last) visible message indices for this area. + /// Returns (0, 0) if nothing is visible. + pub fn visible_range(&self, area: Rect) -> (usize, usize) { + let (visible, _) = self.compute_visible(area); + match (visible.first(), visible.last()) { + (Some(&(first, _)), Some(&(last, _))) => (first, last), + _ => (0, 0), + } + } + /// Compute the positions where inline images should be rendered. /// Returns (file_hash_hex, Rect) pairs. pub fn image_positions(&self, area: Rect) -> Vec<(String, Rect)> { @@ -541,11 +568,13 @@ impl Widget for MessageListWidget<'_> { if inner.height == 0 || inner.width == 0 || self.messages.is_empty() { if self.messages.is_empty() { - let empty = Paragraph::new(Span::styled( - "No messages yet", - Style::default().fg(Color::DarkGray), - )) - .centered(); + let (text, color) = if self.loading { + ("Loading messages...", Color::Yellow) + } else { + ("No messages yet", Color::DarkGray) + }; + let empty = + Paragraph::new(Span::styled(text, Style::default().fg(color))).centered(); empty.render(inner, buf); } return; @@ -612,12 +641,6 @@ impl Widget for MessageListWidget<'_> { } } -/// Calculate the maximum scroll offset for the message list. -#[allow(dead_code)] -pub fn max_scroll(message_count: usize, visible_height: usize) -> usize { - message_count.saturating_sub(visible_height) -} - #[cfg(test)] mod tests { use super::*; @@ -650,13 +673,6 @@ mod tests { assert!(!ts.is_empty()); } - #[test] - fn max_scroll_calculation() { - assert_eq!(max_scroll(100, 20), 80); - assert_eq!(max_scroll(5, 20), 0); - assert_eq!(max_scroll(20, 20), 0); - } - #[test] fn identifies_own_messages() { let my_pk = "abc123"; @@ -1000,4 +1016,100 @@ mod tests { assert_eq!(lines.len(), 1); assert_eq!(message_height(&msg, 80), 1); } + + // ── compute_visible / visible_range tests ────────────────────────── + + /// Build N short (1-row) messages for viewport tests. + fn short_msgs(n: usize) -> Vec { + (0..n) + .map(|i| { + json!({ + "content": format!("msg{i}"), + "author": "a", + "created_at_local": "2026-01-01 10:00:00" + }) + }) + .collect() + } + + /// Helper: compute visible_range for given messages, scroll, and viewport. + fn vis_range(msgs: &[Value], scroll: usize, height: u16, width: u16) -> (usize, usize) { + let w = MessageListWidget::new(msgs, scroll); + w.visible_range(Rect::new(0, 0, width, height)) + } + + #[test] + fn visible_range_at_bottom_shows_newest() { + let msgs = short_msgs(20); + let (first, last) = vis_range(&msgs, 0, 10, 80); + assert_eq!(last, 19, "last visible should be the newest message"); + assert_eq!(first, 10, "first visible should be 10 messages from end"); + } + + #[test] + fn visible_range_scroll_to_top_fills_forward() { + // Regression: pressing 'g' sets scroll = total - 1. This must show + // message 0 at the top and fill the viewport forward — not just 1 message. + let msgs = short_msgs(20); + let scroll = msgs.len() - 1; // = 19, same as pressing 'g' + let (first, last) = vis_range(&msgs, scroll, 10, 80); + assert_eq!(first, 0, "should start at the oldest message"); + assert!( + last >= 9, + "should fill viewport with ~10 messages, got last={last}" + ); + } + + #[test] + fn visible_range_mid_scroll() { + let msgs = short_msgs(30); + let (first, last) = vis_range(&msgs, 10, 10, 80); + // anchor = 29 - 10 = 19, backward fill 10 msgs, forward from 10 + assert_eq!(last, 19); + assert_eq!(first, 10); + } + + #[test] + fn visible_range_all_messages_fit() { + let msgs = short_msgs(5); + let (first, last) = vis_range(&msgs, 0, 20, 80); + assert_eq!(first, 0); + assert_eq!(last, 4, "all 5 messages should be visible"); + } + + #[test] + fn visible_range_oversized_message_not_blank() { + // A message taller than the viewport must still produce a non-empty result. + let msgs = vec![json!({ + "content": "x\n".repeat(50), // 50+ rows + "author": "a", + "created_at_local": "2026-01-01 10:00:00" + })]; + let (first, last) = vis_range(&msgs, 0, 10, 80); + assert_eq!(first, 0, "oversized message must still be visible"); + assert_eq!(last, 0); + } + + #[test] + fn visible_range_oversized_message_mid_list() { + // Short messages around an oversized one: viewport should include it. + let mut msgs = short_msgs(3); + msgs.insert( + 1, + json!({ + "content": "x\n".repeat(50), + "author": "a", + "created_at_local": "2026-01-01 10:00:00" + }), + ); + // total = 4 messages, anchor = msg 3 (scroll=0) + // backward fill from msg 3: msg 2 fits (1 row), msg 1 (50+ rows) doesn't fit + // start = 2, forward render: msg 2, msg 3 + let (_first, last) = vis_range(&msgs, 0, 10, 80); + assert!(last == 3, "newest message should be visible"); + // When scrolling to the oversized message (anchor = 1): + let (first2, last2) = vis_range(&msgs, 2, 10, 80); + assert_eq!(first2, 1, "oversized msg should be first visible"); + assert!(last2 >= 1, "at least the oversized message is shown"); + } } diff --git a/src/wn.rs b/src/wn.rs index 5f20ba2..5dde9e3 100644 --- a/src/wn.rs +++ b/src/wn.rs @@ -119,7 +119,14 @@ fn parse_stream_buf(buf: &mut Vec) -> ParseOutcome { let mut de = serde_json::Deserializer::from_slice(buf).into_iter::(); let val = match de.next() { Some(Ok(v)) => v, - Some(Err(e)) if e.is_eof() => return ParseOutcome::Incomplete, + Some(Err(e)) if e.is_eof() => { + if values.is_empty() { + return ParseOutcome::Incomplete; + } + // Yield already-parsed values; the incomplete tail stays in buf + // and will be completed on the next read. + break; + } Some(Err(_)) => { // Malformed byte — discard and retry buf.remove(0); @@ -491,4 +498,18 @@ mod tests { assert_eq!(vals.len(), 1); assert_eq!(vals[0]["result"]["id"], 1); } + + #[test] + fn stream_parse_yields_complete_values_before_incomplete_tail() { + // Simulate a chunk that has two complete objects followed by a truncated third. + // Before the fix, the Incomplete return discarded the two good values. + let mut buf = + b"{\"result\":{\"id\":1}}{\"result\":{\"id\":2}}{\"result\":{\"id\":3".to_vec(); + let vals = extract_values(parse_stream_buf(&mut buf)); + assert_eq!(vals.len(), 2, "should yield the 2 complete values"); + assert_eq!(vals[0]["result"]["id"], 1); + assert_eq!(vals[1]["result"]["id"], 2); + // The incomplete tail remains in the buffer + assert_eq!(buf, b"{\"result\":{\"id\":3"); + } }