From 35c85650b80e32ed0a010dc245b493e2a6c5fc2d Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Wed, 4 Mar 2026 13:03:38 -0300 Subject: [PATCH 1/5] feat(profile): allow for editing additional fields also add nsec export --- src/action.rs | 4 ++++ src/app.rs | 34 ++++++++++++++++++++++++++++++++-- src/main.rs | 18 ++++++++++++++++++ src/screen/profile.rs | 2 ++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/action.rs b/src/action.rs index 4e35139..3e7ba5a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -48,6 +48,7 @@ pub enum Action { ProfileLoaded(Value), ProfileUpdateSuccess(String), ProfileUpdateError(String), + NsecExported(String), // Settings SettingsLoaded(Value), @@ -146,6 +147,9 @@ pub enum Effect { name: Option, about: Option, }, + ExportNsec { + account: String, + }, // Settings LoadSettings { diff --git a/src/app.rs b/src/app.rs index 2510af6..ff814b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -41,6 +41,10 @@ pub enum Popup { Error { message: String, }, + Info { + title: String, + message: String, + }, } #[derive(Debug, Clone, PartialEq)] @@ -306,6 +310,12 @@ impl App { message: format!("Error: {msg}"), }); } + Action::NsecExported(nsec) => { + self.popup = Some(Popup::Info { + title: "Export nsec".into(), + message: nsec, + }); + } // Settings Action::SettingsLoaded(val) => { @@ -637,8 +647,8 @@ impl App { } _ => vec![], }, - Popup::Help { .. } | Popup::Error { .. } => { - // Any key dismisses help/error popups + Popup::Help { .. } | Popup::Error { .. } | Popup::Info { .. } => { + // Any key dismisses help/error/info popups self.popup = None; vec![] } @@ -1174,6 +1184,13 @@ impl App { } vec![] } + KeyCode::Char('e') => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + vec![Effect::ExportNsec { account }] + } KeyCode::Char('j') | KeyCode::Down => { if !self.follows.is_empty() { self.selected_follow = (self.selected_follow + 1).min(self.follows.len() - 1); @@ -1519,6 +1536,19 @@ impl App { .size(55, 8); frame.render_widget(widget, area); } + Popup::Info { title, message } => { + let widget = PopupWidget::new(title) + .body(vec![ + Line::raw(""), + Line::from(Span::styled( + message.as_str(), + Style::default().fg(Color::Yellow), + )), + ]) + .hints(vec![("Any key", "Dismiss")]) + .size(70, 8); + frame.render_widget(widget, area); + } Popup::Invites { items, selected } => { let body: Vec = items .iter() diff --git a/src/main.rs b/src/main.rs index e338c11..4011e84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -161,6 +161,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::DeclineInvite { .. } => "DeclineInvite", Effect::LoadProfile { .. } => "LoadProfile", Effect::UpdateProfile { .. } => "UpdateProfile", + Effect::ExportNsec { .. } => "ExportNsec", Effect::LoadSettings { .. } => "LoadSettings", Effect::UpdateSetting { .. } => "UpdateSetting", Effect::LoadFollows { .. } => "LoadFollows", @@ -572,6 +573,23 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::ExportNsec { account } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["export-nsec", &account]).await { + Ok(val) => { + let nsec = val + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| val.to_string()); + Action::NsecExported(nsec) + } + Err(e) => Action::ProfileUpdateError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::LoadSettings { account } => { let tx = tx.clone(); tokio::spawn(async move { diff --git a/src/screen/profile.rs b/src/screen/profile.rs index fe18169..0fc8a84 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -161,6 +161,8 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { Span::raw("Edit name "), Span::styled("[a] ", Style::default().fg(Color::Cyan)), Span::raw("Edit about "), + Span::styled("[e] ", Style::default().fg(Color::Cyan)), + Span::raw("Export nsec "), ]; if !app.follows.is_empty() { hints.extend([ From 64cbb3e0e9fe4af8f1d9d3fb9f127b9ca11de133 Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Wed, 4 Mar 2026 20:31:49 -0300 Subject: [PATCH 2/5] feat(profile): allow extracting nsec --- src/action.rs | 6 +++++ src/app.rs | 53 +++++++++++++++++++++++++++++++++---------- src/main.rs | 21 ++++++++++++++++- src/screen/profile.rs | 2 +- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/action.rs b/src/action.rs index 3e7ba5a..c8b72a1 100644 --- a/src/action.rs +++ b/src/action.rs @@ -49,6 +49,7 @@ pub enum Action { ProfileUpdateSuccess(String), ProfileUpdateError(String), NsecExported(String), + NsecExportError(String), // Settings SettingsLoaded(Value), @@ -59,6 +60,7 @@ pub enum Action { FollowsLoaded(Vec), FollowSuccess(String), FollowError(String), + FollowCheckResult { pubkey: String, following: bool }, // User search SearchResult(Value), @@ -174,6 +176,10 @@ pub enum Effect { account: String, pubkey: String, }, + CheckFollow { + account: String, + pubkey: String, + }, // User search SearchUsers { diff --git a/src/app.rs b/src/app.rs index ff814b4..2a5f1d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -129,6 +129,7 @@ pub struct App { pub search_results: Vec, pub selected_result: usize, pub search_purpose: SearchPurpose, + pub follow_checks: HashMap, // Log panel pub show_logs: bool, @@ -175,6 +176,7 @@ impl App { search_results: Vec::new(), selected_result: 0, search_purpose: SearchPurpose::Browse, + follow_checks: HashMap::new(), show_logs: false, logs: Vec::new(), daemon_logs: Vec::new(), @@ -312,10 +314,15 @@ impl App { } Action::NsecExported(nsec) => { self.popup = Some(Popup::Info { - title: "Export nsec".into(), + title: "Show nsec".into(), message: nsec, }); } + Action::NsecExportError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Export nsec failed: {msg}"), + }); + } // Settings Action::SettingsLoaded(val) => { @@ -353,10 +360,26 @@ impl App { Action::FollowError(msg) => { self.popup = Some(Popup::Error { message: msg }); } + Action::FollowCheckResult { pubkey, following } => { + self.follow_checks.insert(pubkey, following); + } // User search Action::SearchResult(val) => { + let pubkey = val + .get("pubkey") + .or_else(|| val.get("npub")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); self.search_results.push(val); + if let (Some(pk), Some(account)) = (pubkey, &self.account) { + if !self.follow_checks.contains_key(&pk) { + return vec![Effect::CheckFollow { + account: account.clone(), + pubkey: pk, + }]; + } + } } Action::SearchStreamEnded => {} @@ -445,8 +468,17 @@ impl App { .count() } - /// Check if a pubkey is in the follows list. + fn clear_search_results(&mut self) { + self.search_results.clear(); + self.follow_checks.clear(); + self.selected_result = 0; + } + + /// Check if a pubkey is followed. Uses authoritative check cache first, falls back to local list. pub fn is_following(&self, pubkey: &str) -> bool { + if let Some(&checked) = self.follow_checks.get(pubkey) { + return checked; + } self.follows .iter() .any(|f| f.get("pubkey").and_then(|v| v.as_str()) == Some(pubkey)) @@ -860,8 +892,7 @@ impl App { KeyCode::Char('/') => { self.screen = Screen::UserSearch; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); self.search_purpose = SearchPurpose::Browse; vec![] } @@ -1052,8 +1083,7 @@ impl App { }; self.screen = Screen::UserSearch; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); self.search_purpose = SearchPurpose::AddMember { group_id }; vec![] } @@ -1248,8 +1278,7 @@ impl App { }; self.screen = back_screen; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); vec![Effect::UnsubscribeSearch] } KeyCode::Enter => { @@ -1273,8 +1302,7 @@ impl App { // Go back to group detail self.screen = Screen::GroupDetail; self.search_input.clear(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); return vec![ Effect::UnsubscribeSearch, Effect::AddMember { @@ -1293,8 +1321,7 @@ impl App { None => return vec![], }; let query = self.search_input.value.clone(); - self.search_results.clear(); - self.selected_result = 0; + self.clear_search_results(); vec![Effect::SearchUsers { account, query }] } else { vec![] @@ -1363,8 +1390,10 @@ impl App { }; if self.is_following(&pubkey) { + self.follow_checks.insert(pubkey.clone(), false); vec![Effect::UnfollowUser { account, pubkey }] } else { + self.follow_checks.insert(pubkey.clone(), true); vec![Effect::FollowUser { account, pubkey }] } } diff --git a/src/main.rs b/src/main.rs index 4011e84..c0a3006 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadFollows { .. } => "LoadFollows", Effect::FollowUser { .. } => "FollowUser", Effect::UnfollowUser { .. } => "UnfollowUser", + Effect::CheckFollow { .. } => "CheckFollow", Effect::SearchUsers { .. } => "SearchUsers", Effect::UnsubscribeSearch => "UnsubscribeSearch", Effect::TailDaemonLog => "TailDaemonLog", @@ -584,7 +585,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m .unwrap_or_else(|| val.to_string()); Action::NsecExported(nsec) } - Err(e) => Action::ProfileUpdateError(e.to_string()), + Err(e) => Action::NsecExportError(e.to_string()), }; let _ = tx.send(Event::Action(action)); }); @@ -659,6 +660,24 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::CheckFollow { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let following = + match wn::exec(&["--account", &account, "follows", "check", &pubkey]).await { + Ok(val) => val + .get("following") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + Err(_) => false, + }; + let _ = tx.send(Event::Action(Action::FollowCheckResult { + pubkey, + following, + })); + }); + } + Effect::SearchUsers { account, query } => { streams.kill_search(); let tx = tx.clone(); diff --git a/src/screen/profile.rs b/src/screen/profile.rs index 0fc8a84..0072e7d 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -162,7 +162,7 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { Span::styled("[a] ", Style::default().fg(Color::Cyan)), Span::raw("Edit about "), Span::styled("[e] ", Style::default().fg(Color::Cyan)), - Span::raw("Export nsec "), + Span::raw("Show nsec "), ]; if !app.follows.is_empty() { hints.extend([ From 7f633b3a5b7fe441b3f68e687ac752dc43ff05e4 Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Thu, 5 Mar 2026 16:33:18 -0300 Subject: [PATCH 3/5] feat: add profile images, extended profile fields, user profiles, group info, and account management Add terminal image rendering for profile pictures using ratatui-image with auto-detection of Kitty/Sixel/iTerm2/Halfblocks protocols. Images display on both the profile screen and user profile popups. Includes a workaround for iTerm2 misdetecting as Kitty by checking ITERM_SESSION_ID env var. Extend profile editing with display_name, picture URL, NIP-05, and lightning address fields. Use instead of for full metadata including picture URLs. Add user profile viewing from follows list (Enter) and search results (F2), group info popup (i) on group detail screen, group picker for adding searched users to existing groups (F4), and create-group-with-user flow (F3). Add logout from profile screen (Q) and account select screen (d), create new identity from main screen (C) and account select (c), and login with nsec from account select (l). Other improvements: log panel visible on all screens, Tab switches log tabs globally, paste into popup text inputs, DM invite name resolution via welcomer_pubkey lookup. Deps: ratatui 0.29 -> 0.30, +ratatui-image 10, +image 0.25 --- Cargo.lock | 1780 ++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 +- src/action.rs | 29 +- src/app.rs | 825 +++++++++++++++-- src/main.rs | 198 +++- src/screen/group_detail.rs | 2 + src/screen/login.rs | 22 +- src/screen/main_screen.rs | 5 +- src/screen/profile.rs | 165 +++- src/screen/user_search.rs | 8 +- src/widget/popup.rs | 2 +- 11 files changed, 2818 insertions(+), 222 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c37e97c..5c167e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,24 +38,91 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bech32" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -48,16 +130,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "bytes" -version = "1.11.1" +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] -name = "cassowary" -version = "0.3.0" +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "castaway" @@ -84,6 +192,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -97,11 +211,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -111,19 +231,71 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "futures-core", "mio", @@ -134,6 +306,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -143,6 +333,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.23.0" @@ -163,7 +373,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -174,7 +384,54 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -198,6 +455,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -226,18 +492,103 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -294,7 +645,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -326,6 +677,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -337,15 +698,59 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -354,6 +759,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -366,7 +777,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -379,14 +790,70 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "icy_sixel" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] [[package]] -name = "indoc" -version = "2.0.7" +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ @@ -403,14 +870,14 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -431,12 +898,47 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.14" @@ -446,6 +948,15 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -458,6 +969,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -475,11 +992,21 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "hashbrown", + "nix", + "winapi", ] [[package]] @@ -488,6 +1015,37 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -500,6 +1058,56 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -507,6 +1115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -521,6 +1139,54 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "bytemuck", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -545,10 +1211,99 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pin-project-lite" @@ -556,6 +1311,50 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -565,6 +1364,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quantette" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" +dependencies = [ + "bitvec", + "bytemuck", + "image", + "libm", + "num-traits", + "ordered-float 5.1.0", + "palette", + "rand 0.9.2", + "rand_xoshiro", + "rayon", + "ref-cast", + "wide", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.44" @@ -574,25 +1405,197 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags", - "cassowary", + "bitflags 2.11.0", "compact_str", - "crossterm", + "hashbrown 0.16.1", "indoc", - "instability", "itertools", + "kasuari", "lru", - "paste", "strum", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-image" +version = "10.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57add959ab80c9a92be620fa6f8e4a64f7c014829250ba78862e8d81a903cb5" +dependencies = [ + "base64-simd", + "icy_sixel", + "image", + "rand 0.8.5", + "ratatui", + "rustix 0.38.44", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -601,7 +1604,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -610,9 +1613,67 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", ] [[package]] @@ -621,7 +1682,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -634,7 +1695,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -653,12 +1714,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" +dependencies = [ + "bytemuck", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -686,7 +1762,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -702,6 +1778,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -739,6 +1826,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -774,57 +1873,177 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strum" -version = "0.26.3" +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "strum_macros", + "thiserror-impl 2.0.18", ] [[package]] -name = "strum_macros" -version = "0.26.4" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.117", ] [[package]] -name = "syn" -version = "2.0.117" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "time" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ - "thiserror-impl", + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tokio" @@ -851,7 +2070,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -865,6 +2084,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -879,26 +2110,65 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "unicode-width" -version = "0.2.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] [[package]] name = "wasi" @@ -906,6 +2176,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -938,7 +2226,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -951,6 +2239,118 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "which" version = "7.0.3" @@ -963,6 +2363,16 @@ dependencies = [ "winsafe", ] +[[package]] +name = "wide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -985,17 +2395,51 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1006,7 +2450,18 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1017,7 +2472,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1026,6 +2481,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1035,6 +2499,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -1206,6 +2680,94 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wn-tui" version = "0.1.0" @@ -1213,20 +2775,66 @@ dependencies = [ "anyhow", "bech32", "chrono", - "crossterm", + "crossterm 0.28.1", "dirs", "futures", + "image", "ratatui", + "ratatui-image", "serde", "serde_json", "tokio", "tokio-stream", - "unicode-width 0.2.0", + "unicode-width", "which", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 102e727..fbfc8c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -ratatui = "0.29" +ratatui = "0.30" crossterm = { version = "0.28", features = ["event-stream"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } @@ -17,3 +17,5 @@ futures = "0.3" tokio-stream = "0.1" which = "7" bech32 = "0.11" +ratatui-image = { version = "10", default-features = false, features = ["crossterm"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } diff --git a/src/action.rs b/src/action.rs index c8b72a1..41138a6 100644 --- a/src/action.rs +++ b/src/action.rs @@ -46,6 +46,7 @@ pub enum Action { // Profile ProfileLoaded(Value), + ProfileImageFetched(Vec), ProfileUpdateSuccess(String), ProfileUpdateError(String), NsecExported(String), @@ -60,11 +61,20 @@ pub enum Action { FollowsLoaded(Vec), FollowSuccess(String), FollowError(String), - FollowCheckResult { pubkey: String, following: bool }, + FollowCheckResult { + pubkey: String, + following: bool, + }, // User search SearchResult(Value), SearchStreamEnded, + UserProfileLoaded(Value), + UserProfileError(String), + + // Account management + LogoutSuccess, + LogoutError(String), // Logs Log(String), @@ -111,6 +121,7 @@ pub enum Effect { CreateGroup { account: String, name: String, + members: Vec, }, AddMember { account: String, @@ -147,11 +158,18 @@ pub enum Effect { UpdateProfile { account: String, name: Option, + display_name: Option, about: Option, + picture: Option, + nip05: Option, + lud16: Option, }, ExportNsec { account: String, }, + FetchProfileImage { + url: String, + }, // Settings LoadSettings { @@ -187,6 +205,15 @@ pub enum Effect { query: String, }, UnsubscribeSearch, + ShowUserProfile { + account: String, + pubkey: String, + }, + + // Account management + Logout { + account: String, + }, // Daemon logs TailDaemonLog, diff --git a/src/app.rs b/src/app.rs index 2a5f1d3..cdd1f46 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; +use ratatui_image::picker::Picker; +use ratatui_image::protocol::StatefulProtocol; use serde_json::Value; use crate::action::{Action, Effect}; @@ -45,21 +47,37 @@ pub enum Popup { title: String, message: String, }, + UserProfile { + data: Value, + }, + GroupInfo { + data: Value, + }, + GroupPicker { + pubkey: String, + selected: usize, + }, } #[derive(Debug, Clone, PartialEq)] pub enum InputPurpose { CreateGroup, + CreateGroupWithUser { pubkey: String }, AddMember, RenameGroup, EditProfileName, + EditDisplayName, EditProfileAbout, + EditPicture, + EditNip05, + EditLud16, } #[derive(Debug, Clone, PartialEq)] pub enum ConfirmPurpose { LeaveGroup, RemoveMember { npub: String }, + Logout { account: String }, } /// Why the user search screen was opened. @@ -114,6 +132,9 @@ pub struct App { // Profile pub profile: Option, + pub picker: Option, + pub profile_image: Option, + pub popup_image: Option, // Settings pub settings_data: Option, @@ -168,6 +189,9 @@ impl App { selected_member: 0, popup: None, profile: None, + picker: None, + profile_image: None, + popup_image: None, settings_data: None, selected_setting: 0, follows: Vec::new(), @@ -297,7 +321,40 @@ impl App { // Profile Action::ProfileLoaded(val) => { + let url = val + .get("picture") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); self.profile = Some(val); + self.profile_image = None; + if let Some(url) = url { + self.logs.push(format!("Fetching profile image: {url}")); + return vec![Effect::FetchProfileImage { url }]; + } + } + Action::ProfileImageFetched(bytes) => { + self.logs + .push(format!("Image received: {} bytes", bytes.len())); + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + let proto = picker.new_resize_protocol(img); + if matches!(self.popup, Some(Popup::UserProfile { .. })) { + self.popup_image = Some(proto); + } else { + self.profile_image = Some(proto); + } + self.logs.push("Image decoded and protocol created".into()); + } + Err(e) => { + self.logs.push(format!("Image decode failed: {e}")); + } + } + } else { + self.logs + .push("No image picker available (terminal may not support images)".into()); + } } Action::ProfileUpdateSuccess(msg) => { self.status_message = Some(msg); @@ -382,6 +439,36 @@ impl App { } } Action::SearchStreamEnded => {} + Action::UserProfileLoaded(data) => { + let url = data + .get("metadata") + .and_then(|m| m.get("picture")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.popup_image = None; + self.popup = Some(Popup::UserProfile { data }); + if let Some(url) = url { + self.logs + .push(format!("Fetching user profile image: {url}")); + return vec![Effect::FetchProfileImage { url }]; + } + } + // Account management + Action::LogoutSuccess => { + return self.handle_logout(); + } + Action::LogoutError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Logout failed: {msg}"), + }); + } + + Action::UserProfileError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Error: {msg}"), + }); + } // Logs Action::Log(msg) => { @@ -455,6 +542,49 @@ impl App { ] } + /// Build an `UpdateProfile` effect, applying a single field via the closure. + fn profile_update( + account: String, + name: Option, + display_name: Option, + about: Option, + picture: Option, + nip05: Option, + lud16: Option, + ) -> Effect { + Effect::UpdateProfile { + account, + name, + display_name, + about, + picture, + nip05, + lud16, + } + } + + fn handle_logout(&mut self) -> Vec { + self.screen = Screen::Login; + self.login_mode = LoginMode::Loading("Checking accounts...".into()); + self.account = None; + self.status_message = None; + self.chats.clear(); + self.messages.clear(); + self.active_group_id = None; + self.unread_counts.clear(); + self.connected = false; + self.popup = None; + self.profile = None; + self.profile_image = None; + self.follows.clear(); + self.follow_checks.clear(); + self.viewing_group_id = None; + self.group_detail = None; + self.group_members.clear(); + self.group_admins.clear(); + vec![Effect::CheckAccounts] + } + /// Total unread messages across all chats. pub fn total_unread(&self) -> usize { self.unread_counts.values().sum() @@ -518,6 +648,13 @@ impl App { } fn handle_paste(&mut self, text: &str) { + // Paste into active popup text input if one is open + if let Some(Popup::TextInput { input, .. }) = &mut self.popup { + for ch in text.chars() { + input.insert(ch); + } + return; + } let input = match self.screen { Screen::Login => Some(&mut self.nsec_input), Screen::UserSearch => Some(&mut self.search_input), @@ -573,8 +710,9 @@ impl App { return vec![]; } - // Tab switches log tabs when log panel is visible - if key.code == KeyCode::Tab && self.show_logs && self.screen == Screen::Main { + // 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) + { self.log_tab = match self.log_tab { LogTab::Activity => LogTab::Daemon, LogTab::Daemon => LogTab::Activity, @@ -679,8 +817,48 @@ impl App { } _ => vec![], }, - Popup::Help { .. } | Popup::Error { .. } | Popup::Info { .. } => { - // Any key dismisses help/error/info popups + Popup::GroupPicker { pubkey, selected } => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !self.chats.is_empty() { + *selected = (*selected + 1).min(self.chats.len() - 1); + } + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + *selected = selected.saturating_sub(1); + vec![] + } + KeyCode::Enter => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let chat = self.chats.get(*selected).cloned(); + let group_id = chat.as_ref().and_then(crate::widget::chat_list::group_id); + let pubkey = pubkey.clone(); + self.popup = None; + match group_id { + Some(gid) => vec![Effect::AddMember { + account, + group_id: gid, + npub: pubkey, + }], + None => vec![], + } + } + KeyCode::Esc => { + self.popup = None; + vec![] + } + _ => vec![], + }, + Popup::Help { .. } + | Popup::Error { .. } + | Popup::Info { .. } + | Popup::UserProfile { .. } + | Popup::GroupInfo { .. } => { + // Any key dismisses help/error/info/profile/group info popups + self.popup_image = None; self.popup = None; vec![] } @@ -698,6 +876,14 @@ impl App { vec![Effect::CreateGroup { account, name: value, + members: vec![], + }] + } + InputPurpose::CreateGroupWithUser { pubkey } => { + vec![Effect::CreateGroup { + account, + name: value, + members: vec![pubkey], }] } InputPurpose::AddMember => { @@ -723,27 +909,84 @@ impl App { }] } InputPurpose::EditProfileName => { - vec![Effect::UpdateProfile { + vec![Self::profile_update( account, - name: Some(value), - about: None, - }] + Some(value), + None, + None, + None, + None, + None, + )] + } + InputPurpose::EditDisplayName => { + vec![Self::profile_update( + account, + None, + Some(value), + None, + None, + None, + None, + )] } InputPurpose::EditProfileAbout => { - vec![Effect::UpdateProfile { + vec![Self::profile_update( account, - name: None, - about: Some(value), - }] + None, + None, + Some(value), + None, + None, + None, + )] + } + InputPurpose::EditPicture => { + vec![Self::profile_update( + account, + None, + None, + None, + Some(value), + None, + None, + )] + } + InputPurpose::EditNip05 => { + vec![Self::profile_update( + account, + None, + None, + None, + None, + Some(value), + None, + )] + } + InputPurpose::EditLud16 => { + vec![Self::profile_update( + account, + None, + None, + None, + None, + None, + Some(value), + )] } } } fn submit_confirm(&mut self, purpose: ConfirmPurpose) -> Vec { + if let ConfirmPurpose::Logout { account } = purpose { + return vec![Effect::Logout { account }]; + } + let account = match &self.account { Some(a) => a.clone(), None => return vec![], }; + let group_id = match &self.viewing_group_id { Some(g) => g.clone(), None => return vec![], @@ -760,6 +1003,7 @@ impl App { npub, }] } + ConfirmPurpose::Logout { .. } => unreachable!(), } } @@ -896,6 +1140,11 @@ impl App { self.search_purpose = SearchPurpose::Browse; vec![] } + KeyCode::Char('C') => { + self.login_mode = LoginMode::Loading("Creating identity...".into()); + self.screen = Screen::Login; + vec![Effect::CreateIdentity] + } KeyCode::Char('q') => { self.running = false; vec![] @@ -1095,6 +1344,14 @@ impl App { }); vec![] } + KeyCode::Char('i') => { + if let Some(detail) = &self.group_detail { + self.popup = Some(Popup::GroupInfo { + data: detail.clone(), + }); + } + vec![] + } KeyCode::Char('x') => self.confirm_remove_member(), KeyCode::Char('R') => { let current_name = self @@ -1167,6 +1424,21 @@ impl App { // ── Profile key handling ───────────────────────────────────────── + fn open_profile_edit(&mut self, title: &str, field: &str, purpose: InputPurpose) { + if let Some(profile) = &self.profile { + let current = profile.get(field).and_then(|v| v.as_str()).unwrap_or(""); + let mut input = Input::new(); + for ch in current.chars() { + input.insert(ch); + } + self.popup = Some(Popup::TextInput { + title: title.into(), + input, + purpose, + }); + } + } + fn handle_profile_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Esc => { @@ -1175,43 +1447,31 @@ impl App { vec![] } KeyCode::Char('n') => { - if self.profile.is_some() { - let current = self - .profile - .as_ref() - .and_then(|p| p.get("name").or_else(|| p.get("display_name"))) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut input = Input::new(); - for ch in current.chars() { - input.insert(ch); - } - self.popup = Some(Popup::TextInput { - title: "Edit Name".into(), - input, - purpose: InputPurpose::EditProfileName, - }); - } + self.open_profile_edit("Edit Name", "name", InputPurpose::EditProfileName); + vec![] + } + KeyCode::Char('D') => { + self.open_profile_edit( + "Edit Display Name", + "display_name", + InputPurpose::EditDisplayName, + ); vec![] } KeyCode::Char('a') => { - if self.profile.is_some() { - let current = self - .profile - .as_ref() - .and_then(|p| p.get("about")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut input = Input::new(); - for ch in current.chars() { - input.insert(ch); - } - self.popup = Some(Popup::TextInput { - title: "Edit About".into(), - input, - purpose: InputPurpose::EditProfileAbout, - }); - } + self.open_profile_edit("Edit About", "about", InputPurpose::EditProfileAbout); + vec![] + } + KeyCode::Char('P') => { + self.open_profile_edit("Edit Picture URL", "picture", InputPurpose::EditPicture); + vec![] + } + KeyCode::Char('5') => { + self.open_profile_edit("Edit NIP-05", "nip05", InputPurpose::EditNip05); + vec![] + } + KeyCode::Char('$') => { + self.open_profile_edit("Edit Lightning Address", "lud16", InputPurpose::EditLud16); vec![] } KeyCode::Char('e') => { @@ -1221,6 +1481,24 @@ impl App { }; vec![Effect::ExportNsec { account }] } + KeyCode::Enter => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + let pubkey = self + .follows + .get(self.selected_follow) + .and_then(|f| f.get("pubkey").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + match pubkey { + Some(pk) => vec![Effect::ShowUserProfile { + account, + pubkey: pk, + }], + None => vec![], + } + } KeyCode::Char('j') | KeyCode::Down => { if !self.follows.is_empty() { self.selected_follow = (self.selected_follow + 1).min(self.follows.len() - 1); @@ -1250,6 +1528,18 @@ impl App { None => vec![], } } + KeyCode::Char('Q') => { + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + self.popup = Some(Popup::Confirm { + title: "Logout".into(), + message: "Log out of this account? (y/n)".into(), + purpose: ConfirmPurpose::Logout { account }, + }); + vec![] + } _ => vec![], } } @@ -1339,6 +1629,45 @@ impl App { vec![] } KeyCode::Tab => self.toggle_follow_selected(), + KeyCode::F(2) => { + // Show selected user's profile + let account = match &self.account { + Some(a) => a.clone(), + None => return vec![], + }; + match self.selected_search_pubkey() { + Some(pk) => vec![Effect::ShowUserProfile { + account, + pubkey: pk, + }], + None => vec![], + } + } + KeyCode::F(3) => { + // Create group with selected user + let pubkey = self.selected_search_pubkey(); + if let Some(pk) = pubkey { + self.popup = Some(Popup::TextInput { + title: "Create Group with User".into(), + input: Input::new(), + purpose: InputPurpose::CreateGroupWithUser { pubkey: pk }, + }); + } + vec![] + } + KeyCode::F(4) => { + // Add selected user to existing group + let pubkey = self.selected_search_pubkey(); + if let Some(pk) = pubkey { + if !self.chats.is_empty() { + self.popup = Some(Popup::GroupPicker { + pubkey: pk, + selected: 0, + }); + } + } + vec![] + } KeyCode::Char(ch) => { self.search_input.insert(ch); vec![] @@ -1371,6 +1700,17 @@ impl App { } } + fn selected_search_pubkey(&self) -> Option { + self.search_results + .get(self.selected_result) + .and_then(|u| { + u.get("pubkey") + .or_else(|| u.get("npub")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_string()) + } + fn toggle_follow_selected(&mut self) -> Vec { let account = match &self.account { Some(a) => a.clone(), @@ -1422,6 +1762,38 @@ impl App { vec![] } } + KeyCode::Char('c') => { + self.login_mode = LoginMode::Loading("Creating identity...".into()); + self.status_message = None; + vec![Effect::CreateIdentity] + } + KeyCode::Char('d') => { + let idx = *selected; + if let Some(account_id) = accounts.get(idx).and_then(extract_account_id) { + let name = accounts + .get(idx) + .and_then(|a| { + a.get("display_name") + .or_else(|| a.get("name")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("this account"); + self.popup = Some(Popup::Confirm { + title: "Logout".into(), + message: format!("Log out of \"{name}\"? (y/n)"), + purpose: ConfirmPurpose::Logout { + account: account_id, + }, + }); + } + vec![] + } + KeyCode::Char('l') => { + self.login_mode = LoginMode::NsecInput; + self.nsec_input.clear(); + self.status_message = None; + vec![] + } KeyCode::Char('q') => { self.running = false; vec![] @@ -1500,24 +1872,42 @@ impl App { // ── Drawing ────────────────────────────────────────────────────── - pub fn draw(&self, frame: &mut Frame) { - let area = frame.area(); + pub fn draw(&mut self, frame: &mut Frame) { + use ratatui::layout::{Constraint, Layout}; + + let full_area = frame.area(); + + // Split for log panel (shown on non-main screens; main screen handles its own) + let show_global_logs = self.show_logs && self.screen != Screen::Main; + let log_height = if show_global_logs { + (full_area.height / 3).max(5) + } else { + 0 + }; + let vertical = Layout::vertical([Constraint::Fill(1), Constraint::Length(log_height)]) + .split(full_area); + let area = vertical[0]; + match &self.screen { Screen::Login => crate::screen::login::draw(self, frame, area), - Screen::Main => crate::screen::main_screen::draw(self, frame, area), + Screen::Main => crate::screen::main_screen::draw(self, frame, full_area), Screen::GroupDetail => crate::screen::group_detail::draw(self, frame, area), 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), } + if show_global_logs { + crate::screen::main_screen::draw_log_panel(self, frame, vertical[1]); + } + // Popup overlay (renders on top of any screen) - if let Some(popup) = &self.popup { - self.draw_popup(popup, frame, area); + if let Some(popup) = self.popup.clone() { + self.draw_popup(&popup, frame, full_area); } } - fn draw_popup(&self, popup: &Popup, frame: &mut Frame, area: ratatui::layout::Rect) { + fn draw_popup(&mut self, popup: &Popup, frame: &mut Frame, area: ratatui::layout::Rect) { use crate::widget::popup::PopupWidget; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; @@ -1578,6 +1968,242 @@ impl App { .size(70, 8); frame.render_widget(widget, area); } + Popup::UserProfile { data } => { + use crate::widget::popup::centered_rect; + use ratatui::widgets::{Paragraph, Wrap}; + + let meta = data.get("metadata"); + let f = |key: &str| -> &str { + meta.and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("(not set)") + }; + let pubkey = data.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""); + let npub = if pubkey.is_empty() { + "(unknown)".to_string() + } else { + hex_to_npub(pubkey) + }; + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + + let has_image = self.popup_image.is_some(); + let img_cols = if has_image { 20u16 } else { 0 }; + let popup_width = if has_image { 90 } else { 70 }; + + let body_lines = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(" Name: ", label), + Span::styled(f("name"), val), + ]), + Line::from(vec![ + Span::styled(" Display Name: ", label), + Span::styled(f("display_name"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" About: ", label), + Span::styled(f("about"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" Picture: ", label), + Span::styled(f("picture"), val), + ]), + Line::from(vec![ + Span::styled(" Website: ", label), + Span::styled(f("website"), val), + ]), + Line::from(vec![ + Span::styled(" NIP-05: ", label), + Span::styled(f("nip05"), val), + ]), + Line::from(vec![ + Span::styled(" Lightning: ", label), + Span::styled(f("lud16"), val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" npub: ", label), + Span::styled(&npub, val), + ]), + ]; + let body_height = body_lines.len() as u16; + let height = (body_height + 4).min(22); + + // Render popup frame with empty body — we'll place text manually + let widget = PopupWidget::new("User Profile") + .hints(vec![("Any key", "Close")]) + .size(popup_width, height); + frame.render_widget(widget, area); + + // Compute popup inner area for manual content placement + let popup_area = centered_rect(popup_width, height, area); + let inner = ratatui::layout::Rect::new( + popup_area.x + 1, + popup_area.y + 1, + popup_area.width.saturating_sub(2), + popup_area.height.saturating_sub(2), + ); + + // Text area: offset by image columns so wrapping stays in bounds + let text_area = ratatui::layout::Rect::new( + inner.x + img_cols, + inner.y, + inner.width.saturating_sub(img_cols), + inner.height.saturating_sub(1), // leave room for hints + ); + frame.render_widget( + Paragraph::new(body_lines).wrap(Wrap { trim: false }), + text_area, + ); + + // Render image on the left side + if let Some(proto) = &mut self.popup_image { + use ratatui_image::StatefulImage; + let img_h = inner.height.saturating_sub(2); + if img_cols > 0 && img_h > 0 { + let img_area = ratatui::layout::Rect::new( + inner.x, + inner.y + 1, // skip the empty first line + img_cols, + img_h, + ); + frame.render_stateful_widget(StatefulImage::default(), img_area, proto); + } + } + } + Popup::GroupInfo { data } => { + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + + let name = data + .get("name") + .or_else(|| data.get("group_name")) + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let description = data + .get("description") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("(none)"); + let state = data + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let epoch = data + .get("epoch") + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "?".into()); + + // Convert byte vec fields to hex string. + // Handles: {"value":{"vec":[u8,...]}}, direct [u8,...], or string. + let bytes_to_hex = |val: &Value| -> String { + if val.is_null() { + return "(unavailable)".into(); + } + if let Some(s) = val.as_str() { + if s.is_empty() { + return "(unavailable)".into(); + } + return s.to_string(); + } + // Try nested {"value":{"vec":[...]}} first, then direct array + let arr = val + .get("value") + .and_then(|v| v.get("vec")) + .and_then(|v| v.as_array()) + .or_else(|| val.as_array()); + match arr { + Some(a) if !a.is_empty() => { + let hex: String = a + .iter() + .filter_map(|b| b.as_u64().map(|n| format!("{:02x}", n))) + .collect(); + if hex.is_empty() { + "(unavailable)".into() + } else { + hex + } + } + _ => "(unavailable)".into(), + } + }; + let mls_id = data + .get("mls_group_id") + .map(bytes_to_hex) + .unwrap_or_else(|| "(unavailable)".into()); + let nostr_id = data + .get("nostr_group_id") + .map(bytes_to_hex) + .unwrap_or_else(|| "(unavailable)".into()); + + let admin_count = data + .get("admin_pubkeys") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + + let last_msg = data + .get("last_message_at") + .and_then(|v| v.as_i64()) + .map(|ts| { + chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }) + .unwrap_or_else(|| ts.to_string()) + }) + .unwrap_or_else(|| "(none)".into()); + + let body = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(" Name: ", label), + Span::styled(name, val), + ]), + Line::from(vec![ + Span::styled(" Description: ", label), + Span::styled(description, val), + ]), + Line::from(vec![ + Span::styled(" State: ", label), + Span::styled(state, val), + ]), + Line::from(vec![ + Span::styled(" Epoch: ", label), + Span::styled(&epoch, val), + ]), + Line::from(vec![ + Span::styled(" Admins: ", label), + Span::styled(admin_count.to_string(), val), + ]), + Line::from(vec![ + Span::styled(" Last message: ", label), + Span::styled(&last_msg, val), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" MLS Group ID: ", label), + Span::styled(&mls_id, val), + ]), + Line::from(vec![ + Span::styled(" Nostr Group ID: ", label), + Span::styled(&nostr_id, val), + ]), + ]; + let height = (body.len() as u16 + 4).min(22); + let widget = PopupWidget::new("Group Info") + .body(body) + .hints(vec![("Any key", "Close")]) + .size(75, height); + frame.render_widget(widget, area); + } Popup::Invites { items, selected } => { let body: Vec = items .iter() @@ -1603,6 +2229,49 @@ impl App { .size(50, height); frame.render_widget(widget, area); } + Popup::GroupPicker { selected, .. } => { + let body: Vec = if self.chats.is_empty() { + vec![Line::styled( + " No groups available", + Style::default().fg(Color::DarkGray), + )] + } else { + self.chats + .iter() + .enumerate() + .map(|(i, chat)| { + let name = chat + .get("name") + .or_else(|| chat.get("group_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let marker = if i == *selected { ">" } else { " " }; + let style = if i == *selected { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + }; + Line::from(vec![ + Span::styled( + format!(" {marker} "), + Style::default().fg(Color::Cyan), + ), + Span::styled(name.to_string(), style), + ]) + }) + .collect() + }; + let height = (body.len() as u16 + 5).min(20); + let widget = PopupWidget::new("Add to Group") + .body(body) + .hints(vec![ + ("Enter", "Add"), + ("j/k", "Navigate"), + ("Esc", "Cancel"), + ]) + .size(50, height); + frame.render_widget(widget, area); + } } } } @@ -1673,6 +2342,7 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("A", "Add by pubkey/npub")); lines.push(hint("x", "Remove member")); lines.push(hint("R", "Rename group")); + lines.push(hint("i", "Group info (epoch, IDs)")); lines.push(hint("L", "Leave group")); lines.push(hint("Esc", "Back")); } @@ -1680,6 +2350,7 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("n", "Edit name")); lines.push(hint("a", "Edit about")); lines.push(hint("j / k", "Navigate follows")); + lines.push(hint("Enter", "View profile")); lines.push(hint("d", "Unfollow")); lines.push(hint("Esc", "Back")); } @@ -1695,6 +2366,9 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("Enter", "Search")); lines.push(hint("↑ / ↓", "Navigate results")); lines.push(hint("Tab", "Follow / Unfollow")); + lines.push(hint("F2", "View profile")); + lines.push(hint("F3", "New group with user")); + lines.push(hint("F4", "Add to existing group")); lines.push(hint("Esc", "Back")); } } @@ -2306,6 +2980,38 @@ mod tests { assert_eq!(app.profile.as_ref().unwrap()["name"], "Alice"); } + #[test] + fn profile_loaded_with_picture_emits_fetch() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + let effects = app.update(Action::ProfileLoaded( + json!({"name": "Alice", "picture": "https://example.com/pic.jpg"}), + )); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::FetchProfileImage { .. }))); + } + + #[test] + fn profile_loaded_without_picture_no_fetch() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + let effects = app.update(Action::ProfileLoaded( + json!({"name": "Alice", "about": "Hi"}), + )); + assert!(!effects + .iter() + .any(|e| matches!(e, Effect::FetchProfileImage { .. }))); + } + + #[test] + fn profile_image_fetched_without_picker_is_noop() { + let mut app = app_on_main(); + app.picker = None; + app.update(Action::ProfileImageFetched(vec![0, 1, 2])); + assert!(app.profile_image.is_none()); + } + #[test] fn profile_n_opens_edit_name() { let mut app = app_on_main(); @@ -2534,6 +3240,21 @@ mod tests { assert_eq!(app.selected_follow, 1); } + #[test] + fn profile_enter_views_selected_follow() { + let mut app = app_on_main(); + app.screen = Screen::Profile; + app.follows = vec![ + json!({"pubkey": "abc", "name": "Alice"}), + json!({"pubkey": "def", "name": "Bob"}), + ]; + app.selected_follow = 1; + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::ShowUserProfile { ref pubkey, .. } if pubkey == "def"))); + } + #[test] fn profile_d_unfollows_selected() { let mut app = app_on_main(); diff --git a/src/main.rs b/src/main.rs index c0a3006..75ceb6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,23 @@ async fn main() -> Result<()> { tui::install_panic_hook(); let mut terminal = tui::init()?; let mut app = App::new(); + + // Initialize image protocol detection (must happen after terminal setup) + match ratatui_image::picker::Picker::from_query_stdio() { + Ok(mut picker) => { + // iTerm2 misdetects as Kitty — override when ITERM_SESSION_ID is set + if std::env::var("ITERM_SESSION_ID").is_ok() { + picker.set_protocol_type(ratatui_image::picker::ProtocolType::Iterm2); + } + app.logs + .push(format!("Image protocol: {:?}", picker.protocol_type())); + app.picker = Some(picker); + } + Err(e) => { + app.logs + .push(format!("Image protocol detection failed: {e}")); + } + } let mut events = EventLoop::new(250); let action_tx = events.sender(); let mut streams = StreamHandles::new(); @@ -162,6 +179,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadProfile { .. } => "LoadProfile", Effect::UpdateProfile { .. } => "UpdateProfile", Effect::ExportNsec { .. } => "ExportNsec", + Effect::FetchProfileImage { .. } => "FetchProfileImage", Effect::LoadSettings { .. } => "LoadSettings", Effect::UpdateSetting { .. } => "UpdateSetting", Effect::LoadFollows { .. } => "LoadFollows", @@ -170,6 +188,8 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::CheckFollow { .. } => "CheckFollow", Effect::SearchUsers { .. } => "SearchUsers", Effect::UnsubscribeSearch => "UnsubscribeSearch", + Effect::ShowUserProfile { .. } => "ShowUserProfile", + Effect::Logout { .. } => "Logout", Effect::TailDaemonLog => "TailDaemonLog", }; send_log(tx, format!("Effect: {effect_name}")); @@ -406,7 +426,62 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m let tx = tx.clone(); tokio::spawn(async move { let action = match wn::exec(&["--account", &account, "groups", "invites"]).await { - Ok(serde_json::Value::Array(arr)) => Action::InvitesLoaded(arr), + Ok(serde_json::Value::Array(mut arr)) => { + // Resolve display names for DM invites (empty group name). + // The welcomer_pubkey in membership is the DM counterpart. + for inv in arr.iter_mut() { + let name = inv + .get("group") + .and_then(|g| g.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + if !name.is_empty() { + continue; + } + let Some(pubkey) = inv + .get("membership") + .and_then(|m| m.get("welcomer_pubkey")) + .and_then(|v| v.as_str()) + else { + continue; + }; + if let Ok(user_val) = + wn::exec(&["--account", &account, "users", "show", pubkey]).await + { + let display_name = user_val + .get("metadata") + .and_then(|m| { + m.get("display_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + m.get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + }) + .or_else(|| { + user_val + .get("display_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + user_val + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + }); + if let Some(resolved) = display_name { + if let Some(group) = inv.get_mut("group") { + group["name"] = + serde_json::Value::String(resolved.to_string()); + } + } + } + } + Action::InvitesLoaded(arr) + } Ok(val) => Action::InvitesLoaded(vec![val]), Err(e) => Action::GroupActionError(e.to_string()), }; @@ -414,14 +489,21 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } - Effect::CreateGroup { account, name } => { + Effect::CreateGroup { + account, + name, + members, + } => { let tx = tx.clone(); tokio::spawn(async move { - let action = - match wn::exec(&["--account", &account, "groups", "create", &name]).await { - Ok(_) => Action::GroupActionSuccess("Group created".into()), - Err(e) => Action::GroupActionError(e.to_string()), - }; + let mut args = vec!["--account", &account, "groups", "create", &name]; + for m in &members { + args.push(m); + } + let action = match wn::exec(&args).await { + Ok(_) => Action::GroupActionSuccess("Group created".into()), + Err(e) => Action::GroupActionError(e.to_string()), + }; let _ = tx.send(Event::Action(action)); }); } @@ -538,10 +620,22 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadProfile { account } => { let tx = tx.clone(); tokio::spawn(async move { - let action = match wn::exec(&["--account", &account, "profile", "show"]).await { - Ok(val) => Action::ProfileLoaded(val), - Err(e) => Action::ProfileUpdateError(e.to_string()), - }; + // Use `users show ` for full metadata (picture, nip05, lud16) + // that `profile show` doesn't return. + let action = + match wn::exec(&["--account", &account, "users", "show", &account]).await { + Ok(val) => { + // Flatten: merge metadata fields to top level for easy access + let mut profile = val.clone(); + if let Some(meta) = val.get("metadata").and_then(|m| m.as_object()) { + for (k, v) in meta { + profile[k.clone()] = v.clone(); + } + } + Action::ProfileLoaded(profile) + } + Err(e) => Action::ProfileUpdateError(e.to_string()), + }; let _ = tx.send(Event::Action(action)); }); } @@ -549,24 +643,36 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::UpdateProfile { account, name, + display_name, about, + picture, + nip05, + lud16, } => { let tx = tx.clone(); tokio::spawn(async move { - let mut args = vec!["--account", &account, "profile", "update"]; - let name_val; - let about_val; - if let Some(ref n) = name { - name_val = n.clone(); - args.push("--name"); - args.push(&name_val); - } - if let Some(ref a) = about { - about_val = a.clone(); - args.push("--about"); - args.push(&about_val); + let mut args = vec![ + "--account".to_string(), + account, + "profile".into(), + "update".into(), + ]; + let fields: &[(&str, &Option)] = &[ + ("--name", &name), + ("--display-name", &display_name), + ("--about", &about), + ("--picture", &picture), + ("--nip05", &nip05), + ("--lud16", &lud16), + ]; + for (flag, val) in fields { + if let Some(v) = val { + args.push(flag.to_string()); + args.push(v.clone()); + } } - let action = match wn::exec(&args).await { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let action = match wn::exec(&arg_refs).await { Ok(_) => Action::ProfileUpdateSuccess("Profile updated".into()), Err(e) => Action::ProfileUpdateError(e.to_string()), }; @@ -591,6 +697,26 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::FetchProfileImage { url } => { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::process::Command::new("curl") + .args(["-s", "-L", "--max-time", "10", &url]) + .output() + .await; + match result { + Ok(output) if output.status.success() && !output.stdout.is_empty() => { + let _ = tx.send(Event::Action(Action::ProfileImageFetched(output.stdout))); + } + _ => { + let _ = tx.send(Event::Action(Action::Log( + "Failed to fetch profile image".into(), + ))); + } + } + }); + } + Effect::LoadSettings { account } => { let tx = tx.clone(); tokio::spawn(async move { @@ -717,6 +843,30 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m streams.kill_search(); } + Effect::ShowUserProfile { account, pubkey } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = + match wn::exec(&["--account", &account, "users", "show", &pubkey]).await { + Ok(val) => Action::UserProfileLoaded(val), + Err(e) => Action::UserProfileError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::Logout { account } => { + streams.kill_all(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&["logout", &account]).await { + Ok(_) => Action::LogoutSuccess, + Err(e) => Action::LogoutError(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 5c97a8a..f632d5f 100644 --- a/src/screen/group_detail.rs +++ b/src/screen/group_detail.rs @@ -235,6 +235,8 @@ fn draw_hints(frame: &mut Frame, area: Rect) { let line2 = Line::from(vec![ Span::styled(" [R] ", Style::default().fg(Color::Cyan)), Span::raw("Rename "), + Span::styled("[i] ", Style::default().fg(Color::Cyan)), + Span::raw("Info "), Span::styled("[L] ", Style::default().fg(Color::Cyan)), Span::raw("Leave "), Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), diff --git a/src/screen/login.rs b/src/screen/login.rs index 9e6d85c..46d2f98 100644 --- a/src/screen/login.rs +++ b/src/screen/login.rs @@ -31,7 +31,7 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(outer, area); let content_height = match &app.login_mode { - LoginMode::AccountSelect { accounts, .. } => (accounts.len() as u16 + 5).min(inner.height), + LoginMode::AccountSelect { accounts, .. } => (accounts.len() as u16 + 6).min(inner.height), _ => 7, }; let vertical = Layout::vertical([ @@ -174,7 +174,8 @@ fn draw_account_select( constraints.push(Constraint::Length(1)); } constraints.push(Constraint::Length(1)); // Spacer - constraints.push(Constraint::Length(1)); // Hints + constraints.push(Constraint::Length(1)); // Hints row 1 + constraints.push(Constraint::Length(1)); // Hints row 2 let rows = Layout::vertical(constraints).split(area); @@ -227,17 +228,28 @@ fn draw_account_select( frame.render_widget(line, rows[2 + i]); } - let hint_row = rows.len() - 1; - let hint = Paragraph::new(Line::from(vec![ + let hint_row1 = rows.len() - 2; + let hint_row2 = rows.len() - 1; + let line1 = Paragraph::new(Line::from(vec![ Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), Span::styled("[Enter] ", Style::default().fg(Color::Cyan)), Span::raw("Select "), + Span::styled("[c] ", Style::default().fg(Color::Cyan)), + Span::raw("New identity "), + Span::styled("[l] ", Style::default().fg(Color::Cyan)), + Span::raw("Login nsec"), + ])) + .centered(); + frame.render_widget(line1, rows[hint_row1]); + let line2 = Paragraph::new(Line::from(vec![ + Span::styled("[d] ", Style::default().fg(Color::Cyan)), + Span::raw("Logout "), Span::styled("[q] ", Style::default().fg(Color::Cyan)), Span::raw("Quit"), ])) .centered(); - frame.render_widget(hint, rows[hint_row]); + frame.render_widget(line2, rows[hint_row2]); } fn draw_loading(msg: &str, frame: &mut Frame, area: Rect) { diff --git a/src/screen/main_screen.rs b/src/screen/main_screen.rs index a0c3676..59def33 100644 --- a/src/screen/main_screen.rs +++ b/src/screen/main_screen.rs @@ -206,6 +206,9 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { key("S"), label("Settings"), sep(), + key("C"), + label("New identity"), + sep(), key("`"), label("Logs"), sep(), @@ -237,7 +240,7 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(Line::from(spans)), area); } -fn draw_log_panel(app: &App, frame: &mut Frame, area: Rect) { +pub fn draw_log_panel(app: &App, frame: &mut Frame, area: Rect) { let (active_label, inactive_label) = match app.log_tab { LogTab::Activity => ("Activity", "Daemon"), LogTab::Daemon => ("Daemon", "Activity"), diff --git a/src/screen/profile.rs b/src/screen/profile.rs index 0072e7d..da800c2 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -5,20 +5,20 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget}, Frame, }; +use ratatui_image::StatefulImage; use crate::app::{hex_to_npub, App}; -/// Extract a profile field with fallback keys. -fn field(profile: &serde_json::Value, keys: &[&str]) -> String { - for key in keys { - if let Some(s) = profile.get(*key).and_then(|v| v.as_str()) { - return s.to_string(); - } +/// 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() { + fallback + } else { + val } - String::new() } -pub fn draw(app: &App, frame: &mut Frame, area: Rect) { +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)) @@ -43,57 +43,102 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { } let profile = app.profile.as_ref().unwrap(); + let f = |key: &str| -> String { + profile + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + + let has_image = app.profile_image.is_some(); + let info_height = 9; let vertical = Layout::vertical([ - Constraint::Length(7), // Profile info - Constraint::Length(1), // Follows header - Constraint::Fill(1), // Follows list - Constraint::Length(1), // Hints + Constraint::Length(info_height), // Profile info (with optional image) + Constraint::Length(1), // Follows header + Constraint::Fill(1), // Follows list + Constraint::Length(2), // Hints (2 rows) ]) .split(inner); - // Profile info - let name = field(profile, &["name", "display_name"]); - let about = field(profile, &["about"]); - let npub = field(profile, &["npub"]); - let npub = if npub.is_empty() { - app.account.as_deref().map(hex_to_npub).unwrap_or_default() - } else if !npub.starts_with("npub") { - hex_to_npub(&npub) + // Profile info section: image (left) + text fields (right) + let name = f("name"); + let display_name = f("display_name"); + let about = f("about"); + let picture = f("picture"); + let nip05 = f("nip05"); + let lud16 = f("lud16"); + let npub = { + let raw = f("npub"); + if raw.is_empty() { + app.account.as_deref().map(hex_to_npub).unwrap_or_default() + } else if !raw.starts_with("npub") { + hex_to_npub(&raw) + } else { + raw + } + }; + + let label = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + let not_set = "(not set)"; + + // Split info area horizontally if we have an image + let (image_area, text_area) = if has_image { + let cols = Layout::horizontal([ + Constraint::Length(20), // Image column + Constraint::Fill(1), // Text fields + ]) + .split(vertical[0]); + (Some(cols[0]), cols[1]) } else { - npub + (None, vertical[0]) }; + // Render profile image + if let Some(img_area) = image_area { + if let Some(protocol) = &mut app.profile_image { + let image_widget = StatefulImage::default(); + frame.render_stateful_widget(image_widget, img_area, protocol); + } + } + let lines = vec![ - Line::raw(""), Line::from(vec![ - Span::styled(" Name: ", Style::default().fg(Color::DarkGray)), - Span::styled( - if name.is_empty() { "(not set)" } else { &name }, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), + Span::styled(" Name: ", label), + Span::styled(display(&name, not_set), val.add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Display name: ", label), + Span::styled(display(&display_name, not_set), val), + ]), + Line::from(vec![ + Span::styled(" About: ", label), + Span::styled(display(&about, not_set), val), ]), - Line::raw(""), Line::from(vec![ - Span::styled(" About: ", Style::default().fg(Color::DarkGray)), + Span::styled(" Picture: ", label), Span::styled( - if about.is_empty() { - "(not set)" - } else { - &about - }, - Style::default().fg(Color::White), + display(&picture, not_set), + Style::default().fg(Color::DarkGray), ), ]), + Line::from(vec![ + Span::styled(" NIP-05: ", label), + Span::styled(display(&nip05, not_set), val), + ]), + Line::from(vec![ + Span::styled(" Lightning: ", label), + Span::styled(display(&lud16, not_set), val), + ]), Line::raw(""), Line::from(vec![ - Span::styled(" npub: ", Style::default().fg(Color::DarkGray)), - Span::styled(npub, Style::default().fg(Color::White)), + Span::styled(" npub: ", label), + Span::styled(npub, val), ]), ]; - frame.render_widget(Paragraph::new(lines), vertical[0]); + frame.render_widget(Paragraph::new(lines), text_area); // Follows header let follows_header = Line::from(vec![Span::styled( @@ -155,26 +200,46 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { StatefulWidget::render(list, vertical[2], frame.buffer_mut(), &mut state); } - // Hints - let mut hints = vec![ + // Hints (2 rows) + let hint_rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(vertical[3]); + + let line1 = Line::from(vec![ Span::styled(" [n] ", Style::default().fg(Color::Cyan)), - Span::raw("Edit name "), + Span::raw("Name "), + Span::styled("[D] ", Style::default().fg(Color::Cyan)), + Span::raw("Display name "), Span::styled("[a] ", Style::default().fg(Color::Cyan)), - Span::raw("Edit about "), + Span::raw("About "), + Span::styled("[P] ", Style::default().fg(Color::Cyan)), + Span::raw("Picture "), + Span::styled("[5] ", Style::default().fg(Color::Cyan)), + Span::raw("NIP-05 "), + Span::styled("[$] ", Style::default().fg(Color::Cyan)), + Span::raw("Lightning "), Span::styled("[e] ", Style::default().fg(Color::Cyan)), - Span::raw("Show nsec "), - ]; + Span::raw("Show nsec"), + ]); + frame.render_widget(Paragraph::new(line1), hint_rows[0]); + + let mut line2_spans = vec![]; if !app.follows.is_empty() { - hints.extend([ - Span::styled("[j/k] ", Style::default().fg(Color::Cyan)), + line2_spans.extend([ + Span::styled(" [j/k] ", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), + Span::styled("[Enter] ", Style::default().fg(Color::Cyan)), + Span::raw("View "), Span::styled("[d] ", Style::default().fg(Color::Cyan)), Span::raw("Unfollow "), ]); + } else { + line2_spans.push(Span::raw(" ")); } - hints.extend([ + line2_spans.extend([ + Span::styled("[Q] ", Style::default().fg(Color::Cyan)), + Span::raw("Logout "), Span::styled("[Esc] ", Style::default().fg(Color::Cyan)), Span::raw("Back"), ]); - frame.render_widget(Paragraph::new(Line::from(hints)), vertical[3]); + frame.render_widget(Paragraph::new(Line::from(line2_spans)), hint_rows[1]); } diff --git a/src/screen/user_search.rs b/src/screen/user_search.rs index 1e75a9e..be2dcd4 100644 --- a/src/screen/user_search.rs +++ b/src/screen/user_search.rs @@ -123,7 +123,13 @@ pub fn draw(app: &App, frame: &mut Frame, area: Rect) { if !app.search_results.is_empty() { hint_spans.extend([ Span::styled("[Tab] ", Style::default().fg(Color::Cyan)), - Span::raw("Follow/Unfollow "), + Span::raw("Follow "), + Span::styled("[F2] ", Style::default().fg(Color::Cyan)), + Span::raw("Profile "), + Span::styled("[F3] ", Style::default().fg(Color::Cyan)), + Span::raw("New group "), + Span::styled("[F4] ", Style::default().fg(Color::Cyan)), + Span::raw("Add to group "), ]); } hint_spans.extend([ diff --git a/src/widget/popup.rs b/src/widget/popup.rs index e4363b6..e3d6c18 100644 --- a/src/widget/popup.rs +++ b/src/widget/popup.rs @@ -137,7 +137,7 @@ impl Widget for PopupWidget<'_> { } /// Calculate a centered rectangle within the given area. -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let w = width.min(area.width.saturating_sub(2)); let h = height.min(area.height.saturating_sub(2)); let x = area.x + (area.width.saturating_sub(w)) / 2; From 500168fd6bd72fada813011287848b887c618b1e Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Sat, 7 Mar 2026 14:36:38 -0300 Subject: [PATCH 4/5] feat: add reactions, message selection, and chat list cleanup Add react/unreact support with emoji popup (r/u keys in Messages panel), selection-based message navigation with viewport-aware scrolling (j/k moves cursor, g/G jumps to oldest/newest), and message kind filtering to only display kind-9 chat messages from the subscription stream. Remove left groups from the chat list immediately on leave, and clear stale entries when re-subscribing after group actions. --- src/action.rs | 23 +++ src/app.rs | 330 ++++++++++++++++++++++++++++++++++++- src/main.rs | 73 ++++++++ src/screen/main_screen.rs | 29 +++- src/widget/message_list.rs | 21 +++ 5 files changed, 466 insertions(+), 10 deletions(-) diff --git a/src/action.rs b/src/action.rs index 41138a6..1b34ec5 100644 --- a/src/action.rs +++ b/src/action.rs @@ -30,6 +30,11 @@ pub enum Action { MessageSent, MessageSendError(String), + // Reactions + ReactionSuccess, + ReactionError(String), + MessagesLoaded(Vec), + // Notifications (streaming) NotificationUpdate(Value), NotificationStreamEnded, @@ -106,6 +111,24 @@ pub enum Effect { text: String, }, + LoadMessages { + account: String, + group_id: String, + }, + + // Reactions + ReactToMessage { + account: String, + group_id: String, + message_id: String, + emoji: String, + }, + UnreactToMessage { + account: String, + group_id: String, + message_id: String, + }, + // Group management LoadGroupDetail { account: String, diff --git a/src/app.rs b/src/app.rs index cdd1f46..460d327 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,6 +71,7 @@ pub enum InputPurpose { EditPicture, EditNip05, EditLud16, + ReactionEmoji, } #[derive(Debug, Clone, PartialEq)] @@ -112,6 +113,8 @@ pub struct App { pub active_group_id: Option, pub messages: Vec, pub message_scroll: usize, + pub selected_message: Option, + pub message_viewport_height: usize, pub composer: Input, // Notifications @@ -179,6 +182,8 @@ impl App { active_group_id: None, messages: Vec::new(), message_scroll: 0, + selected_message: None, + message_viewport_height: 20, // updated each render composer: Input::new(), unread_counts: HashMap::new(), connected: false, @@ -269,6 +274,27 @@ impl App { }); } + // Reactions + Action::ReactionSuccess => { + // Reload messages to get updated reaction counts + if let (Some(account), Some(group_id)) = + (&self.account, &self.active_group_id) + { + return vec![Effect::LoadMessages { + account: account.clone(), + group_id: group_id.clone(), + }]; + } + } + Action::MessagesLoaded(msgs) => { + self.messages = msgs; + } + Action::ReactionError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Reaction failed: {msg}"), + }); + } + // Notifications Action::NotificationUpdate(val) => { self.handle_notification(val); @@ -297,17 +323,29 @@ impl App { Action::GroupActionSuccess(msg) => { self.status_message = Some(msg.clone()); self.popup = None; - // If we left a group, go back to main + // If we left a group, remove from chat list and go back to main if msg.contains("Left group") { + if let Some(gid) = &self.viewing_group_id { + self.chats + .retain(|c| chat_list::group_id(c).as_ref() != Some(gid)); + if !self.chats.is_empty() { + self.selected_chat = self.selected_chat.min(self.chats.len() - 1); + } else { + self.selected_chat = 0; + } + } self.screen = Screen::Main; self.viewing_group_id = None; + return vec![]; } // Reload group detail if still on that screen if self.screen == Screen::GroupDetail { return self.reload_group_detail(); } - // Re-subscribe to chats to pick up changes + // Re-subscribe to chats to pick up changes (e.g., after accepting invite) if let Some(account) = &self.account { + self.chats.clear(); + self.selected_chat = 0; return vec![Effect::SubscribeChats { account: account.clone(), }]; @@ -669,6 +707,13 @@ impl App { } fn handle_message_update(&mut self, val: Value) { + // Only display chat messages (kind 9), skip reactions (7), deletions (5), etc. + if let Some(kind) = val.get("kind").and_then(|v| v.as_u64()) { + if kind != 9 { + return; + } + } + // Deduplicate by message id if let Some(id) = val.get("id").and_then(|v| v.as_str()) { if self @@ -974,6 +1019,12 @@ impl App { Some(value), )] } + InputPurpose::ReactionEmoji => { + if value.is_empty() { + return vec![]; + } + self.react_to_selected(&value) + } } } @@ -1184,21 +1235,131 @@ impl App { ] } + /// Currently selected message index, defaulting to the newest. + fn selected_msg_index(&self) -> Option { + if self.messages.is_empty() { + return None; + } + match self.selected_message { + Some(idx) => Some(idx.min(self.messages.len() - 1)), + None => Some(self.messages.len() - 1), + } + } + + /// Nudge viewport scroll by 1 if the selection moved outside the visible range. + 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); + + if direction > 0 && sel > bottom_visible { + // Selection below viewport — scroll down by 1 + 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; + } + } + } + + fn react_to_selected(&self, emoji: &str) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let message_id = match self.messages[msg_idx].get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + vec![Effect::ReactToMessage { + account, + group_id, + message_id, + emoji: emoji.to_string(), + }] + } + + fn unreact_to_selected(&self) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let message_id = match self.messages[msg_idx].get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + vec![Effect::UnreactToMessage { + account, + group_id, + message_id, + }] + } + fn handle_messages_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Char('k') | KeyCode::Up => { - let max = self.messages.len().saturating_sub(1); - self.message_scroll = (self.message_scroll + 1).min(max); + // Move selection up (toward older messages) + if !self.messages.is_empty() { + let current = self.selected_msg_index().unwrap_or(self.messages.len() - 1); + self.selected_message = Some(current.saturating_sub(1)); + self.scroll_to_follow(-1); + } vec![] } KeyCode::Char('j') | KeyCode::Down => { - self.message_scroll = self.message_scroll.saturating_sub(1); + // Move selection down (toward newer messages) + if !self.messages.is_empty() { + let current = self.selected_msg_index().unwrap_or(self.messages.len() - 1); + let max = self.messages.len() - 1; + self.selected_message = Some((current + 1).min(max)); + self.scroll_to_follow(1); + } vec![] } KeyCode::Char('G') => { - self.message_scroll = 0; + // Jump to newest message + if !self.messages.is_empty() { + self.selected_message = Some(self.messages.len() - 1); + self.message_scroll = 0; + } vec![] } + KeyCode::Char('g') => { + // Jump to oldest message + if !self.messages.is_empty() { + self.selected_message = Some(0); + self.message_scroll = self.messages.len().saturating_sub(1); + } + vec![] + } + KeyCode::Char('r') => { + // Open emoji input popup, pre-filled with default reaction + let mut input = Input::new(); + input.insert('+'); + self.popup = Some(Popup::TextInput { + title: "React with emoji".into(), + input, + purpose: InputPurpose::ReactionEmoji, + }); + vec![] + } + KeyCode::Char('u') => self.unreact_to_selected(), KeyCode::Char('i') | KeyCode::Enter => { self.focus = Panel::Composer; vec![] @@ -1293,6 +1454,7 @@ impl App { self.active_group_id = Some(group_id.clone()); self.messages.clear(); self.message_scroll = 0; + self.selected_message = None; self.focus = Panel::Messages; self.unread_counts.remove(&group_id); @@ -1888,6 +2050,14 @@ impl App { .split(full_area); let area = vertical[0]; + // Update message viewport height estimate for scroll calculations + if self.screen == Screen::Main { + // full_area minus: log panel, hints (1), status bar (1), borders (2), composer (~5) + let chrome = log_height + 1 + 1 + 2 + 5; + self.message_viewport_height = + (full_area.height.saturating_sub(chrome) as usize).max(3); + } + match &self.screen { Screen::Login => crate::screen::login::draw(self, frame, area), Screen::Main => crate::screen::main_screen::draw(self, frame, full_area), @@ -2325,6 +2495,8 @@ fn help_lines(screen: &Screen) -> Vec> { lines.push(hint("Enter", "Select chat")); lines.push(hint("Tab", "Switch focus")); lines.push(hint("i / Enter", "Start typing (messages)")); + lines.push(hint("r", "React to message")); + lines.push(hint("u", "Unreact")); lines.push(hint("Esc", "Unfocus / back")); lines.push(hint("n", "New group")); lines.push(hint("g", "Group info")); @@ -2660,6 +2832,16 @@ mod tests { assert_eq!(app.messages.len(), 0); } + #[test] + fn message_update_skips_reaction_and_deletion_events() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.update(msg_update(json!({"id": "msg1", "content": "hello", "kind": 9}))); + app.update(msg_update(json!({"id": "r1", "content": "+", "kind": 7}))); + app.update(msg_update(json!({"id": "d1", "content": "", "kind": 5}))); + assert_eq!(app.messages.len(), 1, "Only kind-9 chat messages should be kept"); + } + // ── Notifications ──────────────────────────────────────────────── #[test] @@ -2863,6 +3045,24 @@ mod tests { assert_eq!(app.screen, Screen::Main); } + #[test] + fn leave_group_removes_from_chat_list() { + let mut app = app_on_group_detail(); + app.chats = vec![ + json!({"name": "Other", "mls_group_id": "other1"}), + json!({"name": "Coffee Chat", "mls_group_id": "g1"}), + json!({"name": "Third", "mls_group_id": "g3"}), + ]; + app.selected_chat = 1; + app.update(Action::GroupActionSuccess("Left group".into())); + assert_eq!(app.chats.len(), 2); + assert!(app + .chats + .iter() + .all(|c| chat_list::group_id(c).as_deref() != Some("g1"))); + assert!(app.selected_chat < app.chats.len()); + } + #[test] fn group_action_success_reloads_detail() { let mut app = app_on_group_detail(); @@ -3512,6 +3712,124 @@ mod tests { assert_eq!(app.message_scroll, 0, "should auto-scroll"); } + // ── Reactions ───────────────────────────────────────────────────── + + #[test] + fn react_opens_emoji_popup() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "hello", "author": "a"}), + json!({"id": "msg2", "content": "world", "author": "b"}), + ]; + app.update(Action::Key(key(KeyCode::Char('r')))); + match &app.popup { + Some(Popup::TextInput { purpose, input, .. }) => { + assert_eq!(*purpose, InputPurpose::ReactionEmoji); + assert_eq!(input.value, "+"); // pre-filled with default + } + other => panic!("Expected TextInput popup, got {:?}", other), + } + } + + #[test] + fn react_submit_emits_effect() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "hello", "author": "a"}), + ]; + app.selected_message = Some(0); + // Open popup and submit + app.update(Action::Key(key(KeyCode::Char('r')))); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::ReactToMessage { ref message_id, ref emoji, .. } + if message_id == "msg1" && emoji == "+" + ))); + } + + #[test] + fn k_moves_selection_up() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "a", "author": "a"}), + json!({"id": "msg2", "content": "b", "author": "a"}), + json!({"id": "msg3", "content": "c", "author": "a"}), + ]; + // Default: no selection = newest (index 2) + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(1)); + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(0)); + // Clamps at 0 + app.update(Action::Key(key(KeyCode::Char('k')))); + assert_eq!(app.selected_message, Some(0)); + } + + #[test] + fn j_moves_selection_down() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![ + json!({"id": "msg1", "content": "a", "author": "a"}), + json!({"id": "msg2", "content": "b", "author": "a"}), + ]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('j')))); + assert_eq!(app.selected_message, Some(1)); + // Clamps at max + app.update(Action::Key(key(KeyCode::Char('j')))); + assert_eq!(app.selected_message, Some(1)); + } + + #[test] + fn unreact_emits_effect() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({"id": "msg1", "content": "hi", "author": "a"})]; + app.message_scroll = 0; + let effects = app.update(Action::Key(key(KeyCode::Char('u')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::UnreactToMessage { ref message_id, .. } if message_id == "msg1" + ))); + } + + #[test] + fn react_with_no_messages_is_noop() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![]; + let effects = app.update(Action::Key(key(KeyCode::Char('r')))); + assert!(effects.is_empty()); + } + + #[test] + fn react_without_group_is_noop() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = None; + app.messages = vec![json!({"id": "msg1", "content": "hi", "author": "a"})]; + let effects = app.update(Action::Key(key(KeyCode::Char('r')))); + assert!(effects.is_empty()); + } + + #[test] + fn reaction_error_shows_popup() { + let mut app = app_on_main(); + app.update(Action::ReactionError("fail".into())); + assert!(matches!(app.popup, Some(Popup::Error { .. }))); + } + // ── Invite popup navigation ────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index 75ceb6a..2a687ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -166,6 +166,9 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::SubscribeMessages { .. } => "SubscribeMessages", Effect::UnsubscribeMessages => "UnsubscribeMessages", Effect::SendMessage { .. } => "SendMessage", + Effect::LoadMessages { .. } => "LoadMessages", + Effect::ReactToMessage { .. } => "ReactToMessage", + Effect::UnreactToMessage { .. } => "UnreactToMessage", Effect::LoadGroupDetail { .. } => "LoadGroupDetail", Effect::LoadGroupMembers { .. } => "LoadGroupMembers", Effect::LoadInvites { .. } => "LoadInvites", @@ -384,6 +387,76 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::LoadMessages { account, group_id } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "list", + &group_id, + ]) + .await + { + Ok(serde_json::Value::Array(arr)) => Action::MessagesLoaded(arr), + Ok(val) => Action::MessagesLoaded(vec![val]), + Err(e) => Action::Log(format!("Failed to reload messages: {e}")), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::ReactToMessage { + account, + group_id, + message_id, + emoji, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "react", + &group_id, + &message_id, + &emoji, + ]) + .await + { + Ok(_) => Action::ReactionSuccess, + Err(e) => Action::ReactionError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::UnreactToMessage { + account, + group_id, + message_id, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "messages", + "unreact", + &group_id, + &message_id, + ]) + .await + { + Ok(_) => Action::ReactionSuccess, + Err(e) => Action::ReactionError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::LoadGroupDetail { account, group_id } => { let tx = tx.clone(); tokio::spawn(async move { diff --git a/src/screen/main_screen.rs b/src/screen/main_screen.rs index 59def33..ce828a2 100644 --- a/src/screen/main_screen.rs +++ b/src/screen/main_screen.rs @@ -133,9 +133,24 @@ fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { return; } + // Show selection highlight when Messages panel is focused + let selected = if app.focus == Panel::Messages { + app.selected_message + .or_else(|| { + if app.messages.is_empty() { + None + } else { + Some(app.messages.len() - 1) + } + }) + } else { + None + }; + let widget = MessageListWidget::new(&app.messages, app.message_scroll) .block(block) - .my_pubkey(app.account.as_deref()); + .my_pubkey(app.account.as_deref()) + .selected(selected); frame.render_widget(widget, area); } @@ -217,10 +232,16 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { ], Panel::Messages => vec![ key("j/k"), - label("Scroll"), + label("Select"), + sep(), + key("g/G"), + label("Top/Bottom"), + sep(), + key("r"), + label("React"), sep(), - key("G"), - label("Bottom"), + key("u"), + label("Unreact"), sep(), key("i"), label("Compose"), diff --git a/src/widget/message_list.rs b/src/widget/message_list.rs index a05044f..3eb704b 100644 --- a/src/widget/message_list.rs +++ b/src/widget/message_list.rs @@ -164,6 +164,8 @@ pub struct MessageListWidget<'a> { scroll_from_bottom: usize, block: Option>, my_pubkey: Option<&'a str>, + /// If set, highlight this message index (absolute index into messages slice). + selected: Option, } impl<'a> MessageListWidget<'a> { @@ -173,6 +175,7 @@ impl<'a> MessageListWidget<'a> { scroll_from_bottom, block: None, my_pubkey: None, + selected: None, } } @@ -185,6 +188,11 @@ impl<'a> MessageListWidget<'a> { self.my_pubkey = pubkey; self } + + pub fn selected(mut self, selected: Option) -> Self { + self.selected = selected; + self + } } impl Widget for MessageListWidget<'_> { @@ -238,12 +246,25 @@ impl Widget for MessageListWidget<'_> { let msg = &self.messages[idx]; let lines = format_message(msg, self.my_pubkey); let h = message_height(msg, width); + let is_selected = self.selected == Some(idx); let msg_area = Rect::new(inner.x, y, inner.width, h as u16); + Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(msg_area, buf); + if is_selected { + // Apply background highlight after text rendering + for row in msg_area.y..msg_area.y + msg_area.height { + for col in msg_area.x..msg_area.x + msg_area.width { + if let Some(cell) = buf.cell_mut((col, row)) { + cell.set_bg(Color::DarkGray); + } + } + } + } + y += h as u16; if y >= inner.y + inner.height { break; From 14ab90647847272c5c9f4b87e366231c21f0a03c Mon Sep 17 00:00:00 2001 From: "Javier G. Montoya S" Date: Sun, 8 Mar 2026 13:59:00 -0300 Subject: [PATCH 5/5] feat: add media support, message deletion, replies, and file browser - Download, decrypt, and display inline images in chat messages - Open full-size image popup with 'o' key on selected message - Upload media via file browser popup ('U' key) with directory navigation - Reply to messages ('R' key) with quoted context in composer - Delete own messages ('d' key) with confirmation - Message selection highlights with '>' marker and visual feedback - Auto-download image attachments when messages load or arrive - Distinct download state indicators (downloading/loading/failed with reason) - Fail explicitly on unexpected media download responses instead of producing garbage file paths - Fix README links and build instructions for whitenoise-rs rename --- README.md | 13 +- src/action.rs | 54 ++ src/app.rs | 1232 ++++++++++++++++++++++++++++++++++-- src/main.rs | 183 +++++- src/screen/main_screen.rs | 64 +- src/screen/profile.rs | 2 +- src/util.rs | 31 + src/widget/chat_list.rs | 18 +- src/widget/message_list.rs | 551 +++++++++++++++- 9 files changed, 2017 insertions(+), 131 deletions(-) create mode 100644 src/util.rs diff --git a/README.md b/README.md index 72095cf..a0d543a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ![wn-tui](https://blossom.primal.net/ac0d58ade515b45cdc9281f86e2467a9df999394aa28e95ea093cadedf868aaa.png) -A terminal user interface for [WhiteNoise](https://github.com/marmot-protocol/whitenoise), a secure group messenger built on MLS and Nostr. +A terminal user interface for [WhiteNoise](https://github.com/marmot-protocol/whitenoise-rs), a secure group messenger built on MLS and Nostr. -``` +```text +-[Chats]--------+-[Coffee Chat]-------------------------------+ | > Coffee Chat | [10:31] alice: Hey everyone | | Work | [10:32] bob: What's up? | @@ -26,13 +26,12 @@ wn-tui is a pure presentation layer over the WhiteNoise CLI. It spawns `wn` comm ### 1. WhiteNoise daemon and CLI -Build from the [`feat/cli`](https://github.com/marmot-protocol/whitenoise/pull/537) branch of whitenoise-rs: +Build from [whitenoise-rs](https://github.com/marmot-protocol/whitenoise-rs): ```sh -git clone https://github.com/marmot-protocol/whitenoise.git -cd whitenoise -git checkout feat/cli -cargo build --release +git clone https://github.com/marmot-protocol/whitenoise-rs.git +cd whitenoise-rs +cargo build --release --bin wn --features cli --bin wnd ``` This produces two binaries in `target/release/`: diff --git a/src/action.rs b/src/action.rs index 1b34ec5..53451d2 100644 --- a/src/action.rs +++ b/src/action.rs @@ -35,6 +35,35 @@ pub enum Action { ReactionError(String), MessagesLoaded(Vec), + // Message deletion + MessageDeleted { + message_id: String, + }, + MessageDeleteError(String), + + // Media upload + MediaUploaded, + MediaUploadError(String), + + // Media + MediaDownloaded { + file_hash: String, + file_path: String, + }, + MediaDownloadFailed { + file_hash: String, + error: String, + }, + MediaImageLoaded { + file_hash: String, + bytes: Vec, + }, + MediaPopupReady { + bytes: Vec, + img_width: u32, + img_height: u32, + }, + // Notifications (streaming) NotificationUpdate(Value), NotificationStreamEnded, @@ -109,6 +138,7 @@ pub enum Effect { account: String, group_id: String, text: String, + reply_to: Option, }, LoadMessages { @@ -128,6 +158,11 @@ pub enum Effect { group_id: String, message_id: String, }, + DeleteMessage { + account: String, + group_id: String, + message_id: String, + }, // Group management LoadGroupDetail { @@ -238,6 +273,25 @@ pub enum Effect { account: String, }, + // Media + DownloadMedia { + account: String, + group_id: String, + file_hash: String, + }, + LoadMediaImage { + file_hash: String, + file_path: String, + }, + LoadMediaPopup { + file_path: String, + }, + UploadMedia { + account: String, + group_id: String, + file_path: String, + }, + // Daemon logs TailDaemonLog, } diff --git a/src/app.rs b/src/app.rs index 460d327..ee0d4ee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -57,6 +57,15 @@ pub enum Popup { pubkey: String, selected: usize, }, + ImageViewer { + img_width: u32, + img_height: u32, + }, + FileBrowser { + path: std::path::PathBuf, + entries: Vec<(String, bool)>, // (name, is_dir) + selected: usize, + }, } #[derive(Debug, Clone, PartialEq)] @@ -78,6 +87,7 @@ pub enum InputPurpose { pub enum ConfirmPurpose { LeaveGroup, RemoveMember { npub: String }, + DeleteMessage { message_id: String }, Logout { account: String }, } @@ -88,6 +98,14 @@ pub enum SearchPurpose { AddMember { group_id: String }, } +/// State of a media download. +#[derive(Debug, Clone)] +pub enum MediaDownload { + Downloading, + Downloaded(String), // file_path + Failed(String), // error +} + /// Which log tab is active. #[derive(Debug, Clone, PartialEq)] pub enum LogTab { @@ -116,6 +134,8 @@ pub struct App { pub selected_message: Option, pub message_viewport_height: usize, pub composer: Input, + /// Active reply context: (event_id, author_name, content_preview). + pub reply_to: Option<(String, String, String)>, // Notifications pub unread_counts: HashMap, @@ -155,6 +175,10 @@ pub struct App { pub search_purpose: SearchPurpose, pub follow_checks: HashMap, + // Media + pub media_downloads: HashMap, + pub inline_images: HashMap, + // Log panel pub show_logs: bool, pub logs: Vec, @@ -185,6 +209,7 @@ impl App { selected_message: None, message_viewport_height: 20, // updated each render composer: Input::new(), + reply_to: None, unread_counts: HashMap::new(), connected: false, viewing_group_id: None, @@ -206,6 +231,8 @@ impl App { selected_result: 0, search_purpose: SearchPurpose::Browse, follow_checks: HashMap::new(), + media_downloads: HashMap::new(), + inline_images: HashMap::new(), show_logs: false, logs: Vec::new(), daemon_logs: Vec::new(), @@ -261,7 +288,7 @@ impl App { // Message streaming Action::MessageUpdate { group_id, message } => { if self.active_group_id.as_deref() == Some(&group_id) { - self.handle_message_update(message); + return self.handle_message_update(message); } } Action::MessageStreamEnded => {} @@ -277,9 +304,7 @@ impl App { // Reactions Action::ReactionSuccess => { // Reload messages to get updated reaction counts - if let (Some(account), Some(group_id)) = - (&self.account, &self.active_group_id) - { + if let (Some(account), Some(group_id)) = (&self.account, &self.active_group_id) { return vec![Effect::LoadMessages { account: account.clone(), group_id: group_id.clone(), @@ -288,6 +313,27 @@ impl App { } Action::MessagesLoaded(msgs) => { self.messages = msgs; + let mut effects = Vec::new(); + for msg in &self.messages { + for (hash, _, _) in Self::image_attachments(msg) { + if !self.media_downloads.contains_key(&hash) { + if let (Some(account), Some(group_id)) = + (&self.account, &self.active_group_id) + { + self.media_downloads + .insert(hash.clone(), MediaDownload::Downloading); + effects.push(Effect::DownloadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_hash: hash, + }); + } + } + } + } + if !effects.is_empty() { + return effects; + } } Action::ReactionError(msg) => { self.popup = Some(Popup::Error { @@ -295,6 +341,91 @@ impl App { }); } + // Message deletion + Action::MessageDeleted { message_id } => { + self.messages + .retain(|m| m.get("id").and_then(|v| v.as_str()) != Some(&message_id)); + } + Action::MessageDeleteError(msg) => { + self.popup = Some(Popup::Error { + message: format!("Delete failed: {msg}"), + }); + } + + // Media + Action::MediaDownloaded { + file_hash, + file_path, + } => { + self.media_downloads.insert( + file_hash.clone(), + MediaDownload::Downloaded(file_path.clone()), + ); + // Auto-load image for inline display + return vec![Effect::LoadMediaImage { + file_hash, + file_path, + }]; + } + Action::MediaDownloadFailed { file_hash, error } => { + self.logs.push(format!("Media download failed: {error}")); + self.media_downloads + .insert(file_hash, MediaDownload::Failed(error)); + } + Action::MediaImageLoaded { file_hash, bytes } => { + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + self.logs.push(format!( + "Inline image ready: {} ({}x{}, {} bytes)", + &file_hash[..file_hash.len().min(16)], + img.width(), + img.height(), + bytes.len() + )); + let proto = picker.new_resize_protocol(img); + self.inline_images.insert(file_hash, proto); + } + Err(e) => { + self.logs + .push(format!("Image decode failed for {file_hash}: {e}")); + } + } + } + } + + Action::MediaPopupReady { + bytes, + img_width, + img_height, + } => { + if let Some(picker) = &self.picker { + match image::load_from_memory(&bytes) { + Ok(img) => { + let proto = picker.new_resize_protocol(img); + self.popup_image = Some(proto); + self.popup = Some(Popup::ImageViewer { + img_width, + img_height, + }); + } + Err(e) => { + self.logs.push(format!("Popup image decode failed: {e}")); + } + } + } + } + + Action::MediaUploaded => { + self.logs.push("Media uploaded successfully".to_string()); + } + Action::MediaUploadError(e) => { + self.logs.push(format!("Media upload failed: {e}")); + self.popup = Some(Popup::Error { + message: format!("Upload failed: {e}"), + }); + } + // Notifications Action::NotificationUpdate(val) => { self.handle_notification(val); @@ -580,7 +711,7 @@ impl App { ] } - /// Build an `UpdateProfile` effect, applying a single field via the closure. + /// Build an `UpdateProfile` effect from the given field values. fn profile_update( account: String, name: Option, @@ -601,7 +732,9 @@ impl App { } } - fn handle_logout(&mut self) -> Vec { + /// Reset all account-specific UI state and navigate to the account selector. + /// Does NOT call `wn logout` — just clears in-memory state and re-checks accounts. + fn reset_to_account_select(&mut self) -> Vec { self.screen = Screen::Login; self.login_mode = LoginMode::Loading("Checking accounts...".into()); self.account = None; @@ -620,9 +753,16 @@ impl App { self.group_detail = None; self.group_members.clear(); self.group_admins.clear(); + self.media_downloads.clear(); + self.inline_images.clear(); + self.reply_to = None; vec![Effect::CheckAccounts] } + fn handle_logout(&mut self) -> Vec { + self.reset_to_account_select() + } + /// Total unread messages across all chats. pub fn total_unread(&self) -> usize { self.unread_counts.values().sum() @@ -706,11 +846,86 @@ impl App { } } - fn handle_message_update(&mut self, val: Value) { + /// Extract image attachments from a message: (file_hash_hex, mime_type, filename). + /// + /// CLI attachment fields (verified 2026-03-07): + /// `original_file_hash` — hex string or byte vec + /// `mime_type` — e.g. "image/jpeg" + /// `file_metadata` — may contain filename + /// `blossom_url` — fallback for filename extraction + pub fn image_attachments(msg: &Value) -> Vec<(String, String, String)> { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return vec![], + }; + let mut result = Vec::new(); + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + if !mime.starts_with("image/") { + continue; + } + // Extract filename: try file_metadata.original_filename, then filename, then blossom_url basename + let filename = att + .get("file_metadata") + .and_then(|m| m.get("original_filename").or_else(|| m.get("filename"))) + .and_then(|v| v.as_str()) + .or_else(|| att.get("filename").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .or_else(|| { + att.get("blossom_url") + .and_then(|v| v.as_str()) + .and_then(|url| url.rsplit('/').next()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "image".to_string()); + // Hash: try original_file_hash first, then file_hash, then encrypted_file_hash + let hash_hex = crate::util::extract_hash_hex(att, "original_file_hash") + .or_else(|| crate::util::extract_hash_hex(att, "file_hash")) + .or_else(|| crate::util::extract_hash_hex(att, "encrypted_file_hash")); + let hash_hex = match hash_hex { + Some(h) if !h.is_empty() => h, + _ => continue, + }; + result.push((hash_hex, mime.to_string(), filename)); + } + result + } + + /// Emit download effects for image attachments not already tracked. + fn download_effects_for_message(&mut self, msg: &Value) -> Vec { + let (account, group_id) = match (&self.account, &self.active_group_id) { + (Some(a), Some(g)) => (a.clone(), g.clone()), + _ => return vec![], + }; + let attachments = Self::image_attachments(msg); + if !attachments.is_empty() { + self.logs.push(format!( + "Auto-download: found {} image attachment(s)", + attachments.len() + )); + } + let mut effects = Vec::new(); + for (hash, _, filename) in attachments { + if !self.media_downloads.contains_key(&hash) { + self.logs + .push(format!("Auto-download: queuing {filename} (hash={hash})")); + self.media_downloads + .insert(hash.clone(), MediaDownload::Downloading); + effects.push(Effect::DownloadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_hash: hash, + }); + } + } + effects + } + + fn handle_message_update(&mut self, val: Value) -> Vec { // Only display chat messages (kind 9), skip reactions (7), deletions (5), etc. if let Some(kind) = val.get("kind").and_then(|v| v.as_u64()) { if kind != 9 { - return; + return vec![]; } } @@ -721,14 +936,16 @@ impl App { .iter() .any(|m| m.get("id").and_then(|v| v.as_str()) == Some(id)) { - return; + return vec![]; } } + let effects = self.download_effects_for_message(&val); let was_at_bottom = self.message_scroll == 0; self.messages.push(val); if !was_at_bottom { self.message_scroll += 1; } + effects } fn handle_key(&mut self, key: KeyEvent) -> Vec { @@ -897,12 +1114,82 @@ impl App { } _ => vec![], }, + Popup::FileBrowser { + path, + entries, + selected, + } => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !entries.is_empty() { + *selected = (*selected + 1).min(entries.len() - 1); + } + vec![] + } + KeyCode::Char('k') | KeyCode::Up => { + *selected = selected.saturating_sub(1); + vec![] + } + KeyCode::Char('g') => { + *selected = 0; + vec![] + } + KeyCode::Char('G') => { + if !entries.is_empty() { + *selected = entries.len() - 1; + } + vec![] + } + KeyCode::Char('~') => { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); + *entries = Self::scan_dir(&home); + *path = home; + *selected = 0; + vec![] + } + KeyCode::Enter => { + if let Some((name, is_dir)) = entries.get(*selected).cloned() { + if is_dir { + let new_path = path.join(&name); + *entries = Self::scan_dir(&new_path); + *path = new_path; + *selected = 0; + } else { + let file_path = path.join(&name).to_string_lossy().to_string(); + self.popup = None; + if let (Some(account), Some(group_id)) = + (&self.account, &self.active_group_id) + { + return vec![Effect::UploadMedia { + account: account.clone(), + group_id: group_id.clone(), + file_path, + }]; + } + } + } + vec![] + } + KeyCode::Backspace | KeyCode::Char('h') => { + if let Some(parent) = path.parent().map(|p| p.to_path_buf()) { + *entries = Self::scan_dir(&parent); + *path = parent; + *selected = 0; + } + vec![] + } + KeyCode::Esc => { + self.popup = None; + vec![] + } + _ => vec![], + }, Popup::Help { .. } | Popup::Error { .. } | Popup::Info { .. } | Popup::UserProfile { .. } - | Popup::GroupInfo { .. } => { - // Any key dismisses help/error/info/profile/group info popups + | Popup::GroupInfo { .. } + | Popup::ImageViewer { .. } => { + // Any key dismisses help/error/info/profile/group info/image viewer popups self.popup_image = None; self.popup = None; vec![] @@ -910,6 +1197,26 @@ impl App { } } + fn scan_dir(path: &std::path::Path) -> Vec<(String, bool)> { + let mut entries = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(path) { + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + entries.push((name, is_dir)); + } + } + entries.sort_by(|a, b| match (a.1, b.1) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.0.to_lowercase().cmp(&b.0.to_lowercase()), + }); + entries + } + fn submit_text_input(&mut self, purpose: InputPurpose, value: String) -> Vec { let account = match &self.account { Some(a) => a.clone(), @@ -1038,16 +1345,30 @@ impl App { None => return vec![], }; - let group_id = match &self.viewing_group_id { - Some(g) => g.clone(), - None => return vec![], - }; - match purpose { + ConfirmPurpose::DeleteMessage { message_id } => { + let group_id = match &self.active_group_id { + Some(g) => g.clone(), + None => return vec![], + }; + vec![Effect::DeleteMessage { + account, + group_id, + message_id, + }] + } ConfirmPurpose::LeaveGroup => { + let group_id = match &self.viewing_group_id { + Some(g) => g.clone(), + None => return vec![], + }; vec![Effect::LeaveGroup { account, group_id }] } ConfirmPurpose::RemoveMember { npub } => { + let group_id = match &self.viewing_group_id { + Some(g) => g.clone(), + None => return vec![], + }; vec![Effect::RemoveMember { account, group_id, @@ -1196,6 +1517,11 @@ impl App { self.screen = Screen::Login; vec![Effect::CreateIdentity] } + KeyCode::Char('A') => { + // Switch account — reset UI state and go to account selector + // (does NOT call wn logout — just navigates to the picker) + self.reset_to_account_select() + } KeyCode::Char('q') => { self.running = false; vec![] @@ -1311,6 +1637,36 @@ impl App { }] } + fn delete_selected_message(&mut self) -> Vec { + let msg_idx = match self.selected_msg_index() { + Some(i) => i, + None => return vec![], + }; + let msg = &self.messages[msg_idx]; + // Only allow deleting own messages + let author = msg.get("author").and_then(|v| v.as_str()).unwrap_or(""); + if self.account.as_deref() != Some(author) { + return vec![]; + } + let message_id = match msg.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => return vec![], + }; + let preview = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(40) + .collect::(); + self.popup = Some(Popup::Confirm { + title: "Delete Message".into(), + message: format!("Delete \"{preview}\"?"), + purpose: ConfirmPurpose::DeleteMessage { message_id }, + }); + vec![] + } + fn handle_messages_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Char('k') | KeyCode::Up => { @@ -1359,7 +1715,64 @@ impl App { }); vec![] } + KeyCode::Char('o') => { + // Open first downloaded image attachment in full-size popup + if let Some(idx) = self.selected_msg_index() { + if let Some(msg) = self.messages.get(idx) { + for (hash, _, _) in Self::image_attachments(msg) { + if let Some(MediaDownload::Downloaded(path)) = + self.media_downloads.get(&hash) + { + return vec![Effect::LoadMediaPopup { + file_path: path.clone(), + }]; + } + } + } + } + vec![] + } KeyCode::Char('u') => self.unreact_to_selected(), + KeyCode::Char('d') => self.delete_selected_message(), + KeyCode::Char('U') => { + if self.active_group_id.is_some() { + let start = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); + let entries = Self::scan_dir(&start); + self.popup = Some(Popup::FileBrowser { + path: start, + entries, + selected: 0, + }); + } + vec![] + } + KeyCode::Char('R') => { + // Reply to selected message + if let Some(idx) = self.selected_msg_index() { + if let Some(msg) = self.messages.get(idx) { + let event_id = msg.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if event_id.is_empty() { + return vec![]; + } + let author = msg + .get("display_name") + .or_else(|| msg.get("author_name")) + .or_else(|| msg.get("author")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let preview: String = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(30) + .collect(); + self.reply_to = Some((event_id.to_string(), author.to_string(), preview)); + self.focus = Panel::Composer; + } + } + vec![] + } KeyCode::Char('i') | KeyCode::Enter => { self.focus = Panel::Composer; vec![] @@ -1383,18 +1796,22 @@ impl App { if let (Some(account), Some(group_id)) = (&self.account, &self.active_group_id) { let text = self.composer.value.clone(); + let reply_to = self.reply_to.as_ref().map(|(id, _, _)| id.clone()); let effect = Effect::SendMessage { account: account.clone(), group_id: group_id.clone(), text, + reply_to, }; self.composer.clear(); + self.reply_to = None; return vec![effect]; } } vec![] } KeyCode::Esc => { + self.reply_to = None; self.focus = Panel::Messages; vec![] } @@ -1453,6 +1870,9 @@ impl App { self.active_group_id = Some(group_id.clone()); self.messages.clear(); + self.media_downloads.clear(); + self.inline_images.clear(); + self.reply_to = None; self.message_scroll = 0; self.selected_message = None; self.focus = Panel::Messages; @@ -2269,46 +2689,16 @@ impl App { .map(|n| n.to_string()) .unwrap_or_else(|| "?".into()); - // Convert byte vec fields to hex string. - // Handles: {"value":{"vec":[u8,...]}}, direct [u8,...], or string. - let bytes_to_hex = |val: &Value| -> String { - if val.is_null() { - return "(unavailable)".into(); - } - if let Some(s) = val.as_str() { - if s.is_empty() { - return "(unavailable)".into(); - } - return s.to_string(); - } - // Try nested {"value":{"vec":[...]}} first, then direct array - let arr = val - .get("value") - .and_then(|v| v.get("vec")) - .and_then(|v| v.as_array()) - .or_else(|| val.as_array()); - match arr { - Some(a) if !a.is_empty() => { - let hex: String = a - .iter() - .filter_map(|b| b.as_u64().map(|n| format!("{:02x}", n))) - .collect(); - if hex.is_empty() { - "(unavailable)".into() - } else { - hex - } - } - _ => "(unavailable)".into(), - } + let to_hex_display = |val: &Value| -> String { + crate::util::value_to_hex(val).unwrap_or_else(|| "(unavailable)".into()) }; let mls_id = data .get("mls_group_id") - .map(bytes_to_hex) + .map(to_hex_display) .unwrap_or_else(|| "(unavailable)".into()); let nostr_id = data .get("nostr_group_id") - .map(bytes_to_hex) + .map(to_hex_display) .unwrap_or_else(|| "(unavailable)".into()); let admin_count = data @@ -2442,6 +2832,143 @@ impl App { .size(50, height); frame.render_widget(widget, area); } + Popup::ImageViewer { + img_width, + img_height, + } => { + use crate::widget::popup::centered_rect; + use ratatui::layout::Rect; + use ratatui::widgets::{Block, Paragraph}; + use ratatui_image::StatefulImage; + + // Size popup to image aspect ratio. + // Terminal cells are ~2:1 (twice as tall as wide), so divide + // pixel height by 2 when converting to cell rows. + let max_w = area.width.saturating_sub(4); + let max_h = area.height.saturating_sub(4); + let aspect = *img_width as f64 / (*img_height).max(1) as f64; + + // Start with max width, compute height from aspect ratio + // cell_height = pixel_height / 2 relative to width + let mut w = max_w; + let mut h = (w as f64 / aspect / 2.0).ceil() as u16; + + // If height exceeds max, scale down from height instead + if h > max_h { + h = max_h; + w = (h as f64 * aspect * 2.0).ceil() as u16; + } + + // Add 2 for borders + 1 for hint line + let popup_w = (w + 2).min(area.width); + let popup_h = (h + 3).min(area.height); + let popup_area = centered_rect(popup_w, popup_h, area); + + let block = Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Image Viewer "); + let inner = block.inner(popup_area); + + // Clear background + frame.render_widget(ratatui::widgets::Clear, popup_area); + frame.render_widget(block, popup_area); + + if let Some(proto) = &mut self.popup_image { + if inner.width > 0 && inner.height > 1 { + let img_area = Rect::new( + inner.x, + inner.y, + inner.width, + inner.height.saturating_sub(1), + ); + frame.render_stateful_widget(StatefulImage::default(), img_area, proto); + } + } + + // Hint at bottom + let hint_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + 1, + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " Any key ", + Style::default().fg(Color::Black).bg(Color::DarkGray), + ), + Span::styled(" Close", Style::default().fg(Color::DarkGray)), + ])), + hint_area, + ); + } + Popup::FileBrowser { + path, + entries, + selected, + } => { + let title = { + let p = path.display().to_string(); + if p.len() > 56 { + format!("...{}", &p[p.len() - 53..]) + } else { + p + } + }; + let body: Vec = if entries.is_empty() { + vec![Line::styled( + " (empty directory)", + Style::default().fg(Color::DarkGray), + )] + } else { + entries + .iter() + .enumerate() + .map(|(i, (name, is_dir))| { + let marker = if i == *selected { ">" } else { " " }; + let display = if *is_dir { + format!("{name}/") + } else { + name.clone() + }; + let style = if i == *selected { + if *is_dir { + Style::default() + .fg(Color::Cyan) + .add_modifier(ratatui::style::Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + } + } else if *is_dir { + Style::default().fg(Color::Blue) + } else { + Style::default().fg(Color::White) + }; + Line::from(vec![ + Span::styled( + format!(" {marker} "), + Style::default().fg(Color::Cyan), + ), + Span::styled(display, style), + ]) + }) + .collect() + }; + let height = (body.len() as u16 + 5).min(20); + let widget = PopupWidget::new(&title) + .body(body) + .hints(vec![ + ("Enter", "Open"), + ("j/k", "Navigate"), + ("Bksp", "Parent"), + ("~", "Home"), + ("Esc", "Cancel"), + ]) + .size(60, height); + frame.render_widget(widget, area); + } } } } @@ -2520,10 +3047,16 @@ fn help_lines(screen: &Screen) -> Vec> { } Screen::Profile => { lines.push(hint("n", "Edit name")); + lines.push(hint("D", "Edit display name")); lines.push(hint("a", "Edit about")); + lines.push(hint("P", "Edit picture URL")); + lines.push(hint("5", "Edit NIP-05")); + lines.push(hint("$", "Edit lightning address")); + lines.push(hint("e", "Show nsec")); lines.push(hint("j / k", "Navigate follows")); lines.push(hint("Enter", "View profile")); lines.push(hint("d", "Unfollow")); + lines.push(hint("Q", "Logout")); lines.push(hint("Esc", "Back")); } Screen::Settings => { @@ -2836,10 +3369,16 @@ mod tests { fn message_update_skips_reaction_and_deletion_events() { let mut app = app_on_main(); app.active_group_id = Some("g1".into()); - app.update(msg_update(json!({"id": "msg1", "content": "hello", "kind": 9}))); + app.update(msg_update( + json!({"id": "msg1", "content": "hello", "kind": 9}), + )); app.update(msg_update(json!({"id": "r1", "content": "+", "kind": 7}))); app.update(msg_update(json!({"id": "d1", "content": "", "kind": 5}))); - assert_eq!(app.messages.len(), 1, "Only kind-9 chat messages should be kept"); + assert_eq!( + app.messages.len(), + 1, + "Only kind-9 chat messages should be kept" + ); } // ── Notifications ──────────────────────────────────────────────── @@ -3738,9 +4277,7 @@ mod tests { let mut app = app_on_main(); app.focus = Panel::Messages; app.active_group_id = Some("g1".into()); - app.messages = vec![ - json!({"id": "msg1", "content": "hello", "author": "a"}), - ]; + app.messages = vec![json!({"id": "msg1", "content": "hello", "author": "a"})]; app.selected_message = Some(0); // Open popup and submit app.update(Action::Key(key(KeyCode::Char('r')))); @@ -4026,4 +4563,589 @@ mod tests { app.update(Action::Paste("should be ignored".into())); assert!(app.composer.is_empty()); } + + // ── Media / image_attachments tests ───────────────────────────── + + #[test] + fn image_attachments_extracts_image_hashes() { + let msg = json!({ + "content": "check this", + "media_attachments": [ + { + "file_hash": "abc123", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "abc123"); + assert_eq!(result[0].1, "image/png"); + assert_eq!(result[0].2, "photo.png"); + } + + #[test] + fn image_attachments_skips_non_images() { + let msg = json!({ + "content": "file here", + "media_attachments": [ + { + "file_hash": "abc123", + "mime_type": "application/pdf", + "filename": "doc.pdf" + } + ] + }); + let result = App::image_attachments(&msg); + assert!(result.is_empty()); + } + + #[test] + fn image_attachments_handles_byte_vec_hash() { + let msg = json!({ + "content": "img", + "media_attachments": [ + { + "file_hash": {"value": {"vec": [0xab, 0xcd, 0xef]}}, + "mime_type": "image/jpeg", + "filename": "img.jpg" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "abcdef"); + } + + #[test] + fn image_attachments_real_cli_format() { + // Matches actual `wn messages list --json` output (verified 2026-03-07) + let msg = json!({ + "content": "", + "kind": 9, + "media_attachments": [ + { + "account_pubkey": "abc123", + "blossom_url": "https://blossom.primal.net/d012c620", + "mime_type": "image/jpeg", + "original_file_hash": [234, 240, 211, 181], + "encrypted_file_hash": [208, 18, 198, 32], + "file_metadata": { + "original_filename": "signal-2026-03-03.jpeg" + }, + "file_path": "", + "media_type": "chat_media" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 1, "Should find 1 image attachment"); + assert_eq!(result[0].0, "eaf0d3b5", "Hash should be hex of byte array"); + assert_eq!(result[0].1, "image/jpeg"); + assert_eq!( + result[0].2, "signal-2026-03-03.jpeg", + "Should use original_filename" + ); + } + + #[test] + fn image_attachments_empty_when_no_attachments() { + let msg = json!({"content": "hello"}); + assert!(App::image_attachments(&msg).is_empty()); + } + + #[test] + fn image_attachments_mixed_types() { + let msg = json!({ + "content": "mixed", + "media_attachments": [ + { + "file_hash": "hash1", + "mime_type": "image/png", + "filename": "photo.png" + }, + { + "file_hash": "hash2", + "mime_type": "application/zip", + "filename": "archive.zip" + }, + { + "file_hash": "hash3", + "mime_type": "image/gif", + "filename": "anim.gif" + } + ] + }); + let result = App::image_attachments(&msg); + assert_eq!(result.len(), 2); + assert_eq!(result[0].0, "hash1"); + assert_eq!(result[1].0, "hash3"); + } + + #[test] + fn media_downloaded_updates_state() { + let mut app = app_on_main(); + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.update(Action::MediaDownloaded { + file_hash: "hash1".into(), + file_path: "/tmp/img.png".into(), + }); + assert!(matches!( + app.media_downloads.get("hash1"), + Some(MediaDownload::Downloaded(p)) if p == "/tmp/img.png" + )); + } + + #[test] + fn media_download_failed_updates_state() { + let mut app = app_on_main(); + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.update(Action::MediaDownloadFailed { + file_hash: "hash1".into(), + error: "not found".into(), + }); + assert!(matches!( + app.media_downloads.get("hash1"), + Some(MediaDownload::Failed(_)) + )); + } + + #[test] + fn select_chat_clears_media_downloads() { + let mut app = app_on_main(); + app.chats = vec![json!({"name": "test", "mls_group_id": "aabb"})]; + app.media_downloads + .insert("hash1".into(), MediaDownload::Downloading); + app.selected_chat = 0; + app.active_group_id = Some("other".into()); + app.update(Action::Key(key(KeyCode::Enter))); + assert!(app.media_downloads.is_empty()); + } + + #[test] + fn message_update_triggers_download_for_image() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + let msg = json!({ + "id": "m1", + "kind": 9, + "content": "look", + "author": "a", + "media_attachments": [ + { + "file_hash": "imghash", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let effects = app.update(Action::MessageUpdate { + group_id: "g1".into(), + message: msg, + }); + assert!(effects.iter().any( + |e| matches!(e, Effect::DownloadMedia { ref file_hash, .. } if file_hash == "imghash") + )); + assert!(matches!( + app.media_downloads.get("imghash"), + Some(MediaDownload::Downloading) + )); + } + + #[test] + fn o_opens_image_when_downloaded() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "m1", + "content": "img", + "author": "a", + "media_attachments": [ + { + "file_hash": "imghash", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + })]; + app.selected_message = Some(0); + app.media_downloads.insert( + "imghash".into(), + MediaDownload::Downloaded("/tmp/photo.png".into()), + ); + let effects = app.update(Action::Key(key(KeyCode::Char('o')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::LoadMediaPopup { + ref file_path, + .. + } if file_path == "/tmp/photo.png" + ))); + } + + #[test] + fn o_noop_when_no_image_downloaded() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "m1", + "content": "text only", + "author": "a" + })]; + app.selected_message = Some(0); + let effects = app.update(Action::Key(key(KeyCode::Char('o')))); + assert!(effects.is_empty()); + } + + #[test] + fn switch_account_resets_state_and_checks_accounts() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.chats = vec![json!({"name": "test"})]; + app.messages = vec![json!({"content": "hi"})]; + app.focus = Panel::ChatList; + let effects = app.update(Action::Key(key(KeyCode::Char('A')))); + assert_eq!(app.screen, Screen::Login); + assert!(app.account.is_none()); + assert!(app.chats.is_empty()); + assert!(app.messages.is_empty()); + assert!(app.active_group_id.is_none()); + assert!(effects.iter().any(|e| matches!(e, Effect::CheckAccounts))); + } + + #[test] + fn d_opens_confirm_for_own_message() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("mypub".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "msg1", + "content": "my message", + "author": "mypub" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('d')))); + assert!(matches!( + app.popup, + Some(Popup::Confirm { + purpose: ConfirmPurpose::DeleteMessage { .. }, + .. + }) + )); + } + + #[test] + fn d_noop_for_others_message() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("mypub".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "msg1", + "content": "their message", + "author": "otherpub" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('d')))); + assert!(app.popup.is_none()); + } + + #[test] + fn confirm_delete_emits_effect() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::Confirm { + title: "Delete".into(), + message: "Delete?".into(), + purpose: ConfirmPurpose::DeleteMessage { + message_id: "msg1".into(), + }, + }); + let effects = app.update(Action::Key(key(KeyCode::Char('y')))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::DeleteMessage { + ref message_id, + .. + } if message_id == "msg1" + ))); + } + + #[test] + fn message_deleted_removes_from_list() { + let mut app = app_on_main(); + app.messages = vec![ + json!({"id": "m1", "content": "first"}), + json!({"id": "m2", "content": "second"}), + ]; + app.update(Action::MessageDeleted { + message_id: "m1".into(), + }); + assert_eq!(app.messages.len(), 1); + assert_eq!(app.messages[0].get("id").unwrap().as_str().unwrap(), "m2"); + } + + #[test] + fn reply_sets_context_and_focuses_composer() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.messages = vec![json!({ + "id": "evt1", + "content": "hello world", + "author": "somepub", + "display_name": "Alice" + })]; + app.selected_message = Some(0); + app.update(Action::Key(key(KeyCode::Char('R')))); + assert_eq!(app.focus, Panel::Composer); + let (event_id, author, preview) = app.reply_to.as_ref().unwrap(); + assert_eq!(event_id, "evt1"); + assert_eq!(author, "Alice"); + assert_eq!(preview, "hello world"); + } + + #[test] + fn send_with_reply_includes_reply_to() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.reply_to = Some(("evt1".into(), "Alice".into(), "hello".into())); + app.composer.insert('h'); + app.composer.insert('i'); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::SendMessage { + reply_to: Some(ref id), + .. + } if id == "evt1" + ))); + assert!( + app.reply_to.is_none(), + "reply_to should be cleared after send" + ); + } + + #[test] + fn send_without_reply_has_none() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.composer.insert('h'); + app.composer.insert('i'); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::SendMessage { reply_to: None, .. }))); + } + + #[test] + fn esc_from_composer_clears_reply() { + let mut app = app_on_main(); + app.focus = Panel::Composer; + app.active_group_id = Some("g1".into()); + app.reply_to = Some(("evt1".into(), "Alice".into(), "hello".into())); + app.update(Action::Key(key(KeyCode::Esc))); + assert!(app.reply_to.is_none()); + assert_eq!(app.focus, Panel::Messages); + } + + #[test] + fn u_uppercase_opens_file_browser() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = Some("g1".into()); + let effects = app.update(Action::Key(key(KeyCode::Char('U')))); + assert!(effects.is_empty()); + match &app.popup { + Some(Popup::FileBrowser { + path, + entries, + selected, + }) => { + assert_eq!(*selected, 0); + assert!(path.exists()); + // entries should be sorted: dirs first + let dir_section_ended = entries.iter().position(|(_, is_dir)| !is_dir); + if let Some(first_file) = dir_section_ended { + for (_, is_dir) in &entries[..first_file] { + assert!(is_dir); + } + } + } + other => panic!("Expected FileBrowser popup, got {:?}", other), + } + } + + #[test] + fn u_uppercase_noop_without_active_group() { + let mut app = app_on_main(); + app.focus = Panel::Messages; + app.active_group_id = None; + app.update(Action::Key(key(KeyCode::Char('U')))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_select_file_emits_upload() { + let mut app = app_on_main(); + app.account = Some("acc1".into()); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![("docs".into(), true), ("photo.png".into(), false)], + selected: 1, // selecting the file + }); + let effects = app.update(Action::Key(key(KeyCode::Enter))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::UploadMedia { + ref account, + ref group_id, + ref file_path, + } if account == "acc1" && group_id == "g1" && file_path == "/tmp/photo.png" + ))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_enter_directory() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![("subdir".into(), true), ("file.txt".into(), false)], + selected: 0, // selecting the directory + }); + app.update(Action::Key(key(KeyCode::Enter))); + match &app.popup { + Some(Popup::FileBrowser { path, selected, .. }) => { + assert_eq!(path, &std::path::PathBuf::from("/tmp/subdir")); + assert_eq!(*selected, 0); + } + other => panic!( + "Expected FileBrowser popup after dir enter, got {:?}", + other + ), + } + } + + #[test] + fn file_browser_navigation() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp"), + entries: vec![ + ("a".into(), false), + ("b".into(), false), + ("c".into(), false), + ], + selected: 0, + }); + // j moves down + app.update(Action::Key(key(KeyCode::Char('j')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 1), + _ => panic!("expected FileBrowser"), + } + // k moves up + app.update(Action::Key(key(KeyCode::Char('k')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 0), + _ => panic!("expected FileBrowser"), + } + // G jumps to bottom + app.update(Action::Key(key(KeyCode::Char('G')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 2), + _ => panic!("expected FileBrowser"), + } + // g jumps to top + app.update(Action::Key(key(KeyCode::Char('g')))); + match &app.popup { + Some(Popup::FileBrowser { selected, .. }) => assert_eq!(*selected, 0), + _ => panic!("expected FileBrowser"), + } + // Esc closes + app.update(Action::Key(key(KeyCode::Esc))); + assert!(app.popup.is_none()); + } + + #[test] + fn file_browser_backspace_goes_to_parent() { + let mut app = app_on_main(); + app.active_group_id = Some("g1".into()); + app.popup = Some(Popup::FileBrowser { + path: std::path::PathBuf::from("/tmp/subdir"), + entries: vec![("file.txt".into(), false)], + selected: 0, + }); + app.update(Action::Key(key(KeyCode::Backspace))); + match &app.popup { + Some(Popup::FileBrowser { path, selected, .. }) => { + assert_eq!(path, &std::path::PathBuf::from("/tmp")); + assert_eq!(*selected, 0); + } + other => panic!("Expected FileBrowser after backspace, got {:?}", other), + } + } + + #[test] + fn scan_dir_sorts_dirs_first_and_hides_dotfiles() { + use std::fs; + let tmp = std::env::temp_dir().join("wn_tui_test_scan_dir"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("banana.txt"), "").unwrap(); + fs::write(tmp.join("apple.txt"), "").unwrap(); + fs::create_dir(tmp.join("zebra_dir")).unwrap(); + fs::create_dir(tmp.join("alpha_dir")).unwrap(); + fs::write(tmp.join(".hidden"), "").unwrap(); + + let entries = App::scan_dir(&tmp); + // Hidden files excluded + assert!(!entries.iter().any(|(n, _)| n.starts_with('.'))); + // Dirs come first + assert_eq!(entries[0], ("alpha_dir".into(), true)); + assert_eq!(entries[1], ("zebra_dir".into(), true)); + // Files after dirs + assert_eq!(entries[2], ("apple.txt".into(), false)); + assert_eq!(entries[3], ("banana.txt".into(), false)); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn upload_error_shows_popup() { + let mut app = app_on_main(); + app.update(Action::MediaUploadError("file not found".into())); + match &app.popup { + Some(Popup::Error { message }) => { + assert!(message.contains("file not found")); + } + other => panic!("Expected Error popup, got {:?}", other), + } + } + + #[test] + fn upload_success_logs() { + let mut app = app_on_main(); + app.update(Action::MediaUploaded); + assert!(app.logs.iter().any(|l| l.contains("uploaded successfully"))); + } } diff --git a/src/main.rs b/src/main.rs index 2a687ce..aef1ca3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod event; mod screen; mod tui; +pub mod util; mod widget; mod wn; @@ -169,6 +170,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::LoadMessages { .. } => "LoadMessages", Effect::ReactToMessage { .. } => "ReactToMessage", Effect::UnreactToMessage { .. } => "UnreactToMessage", + Effect::DeleteMessage { .. } => "DeleteMessage", Effect::LoadGroupDetail { .. } => "LoadGroupDetail", Effect::LoadGroupMembers { .. } => "LoadGroupMembers", Effect::LoadInvites { .. } => "LoadInvites", @@ -193,6 +195,10 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m Effect::UnsubscribeSearch => "UnsubscribeSearch", Effect::ShowUserProfile { .. } => "ShowUserProfile", Effect::Logout { .. } => "Logout", + Effect::DownloadMedia { .. } => "DownloadMedia", + Effect::LoadMediaImage { .. } => "LoadMediaImage", + Effect::LoadMediaPopup { .. } => "LoadMediaPopup", + Effect::UploadMedia { .. } => "UploadMedia", Effect::TailDaemonLog => "TailDaemonLog", }; send_log(tx, format!("Effect: {effect_name}")); @@ -373,45 +379,74 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m account, group_id, text, + reply_to, } => { + let tx = tx.clone(); + tokio::spawn(async move { + let mut args = vec![ + "--account".to_string(), + account, + "messages".to_string(), + "send".to_string(), + group_id, + text, + ]; + if let Some(event_id) = reply_to { + args.push("--reply-to".to_string()); + args.push(event_id); + } + let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let action = match wn::exec(&args_ref).await { + Ok(_) => Action::MessageSent, + Err(e) => Action::MessageSendError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::LoadMessages { account, group_id } => { let tx = tx.clone(); tokio::spawn(async move { let action = - match wn::exec(&["--account", &account, "messages", "send", &group_id, &text]) - .await - { - Ok(_) => Action::MessageSent, - Err(e) => Action::MessageSendError(e.to_string()), + match wn::exec(&["--account", &account, "messages", "list", &group_id]).await { + Ok(serde_json::Value::Array(arr)) => Action::MessagesLoaded(arr), + Ok(val) => Action::MessagesLoaded(vec![val]), + Err(e) => Action::Log(format!("Failed to reload messages: {e}")), }; let _ = tx.send(Event::Action(action)); }); } - Effect::LoadMessages { account, group_id } => { + Effect::ReactToMessage { + account, + group_id, + message_id, + emoji, + } => { let tx = tx.clone(); tokio::spawn(async move { let action = match wn::exec(&[ "--account", &account, "messages", - "list", + "react", &group_id, + &message_id, + &emoji, ]) .await { - Ok(serde_json::Value::Array(arr)) => Action::MessagesLoaded(arr), - Ok(val) => Action::MessagesLoaded(vec![val]), - Err(e) => Action::Log(format!("Failed to reload messages: {e}")), + Ok(_) => Action::ReactionSuccess, + Err(e) => Action::ReactionError(e.to_string()), }; let _ = tx.send(Event::Action(action)); }); } - Effect::ReactToMessage { + Effect::UnreactToMessage { account, group_id, message_id, - emoji, } => { let tx = tx.clone(); tokio::spawn(async move { @@ -419,10 +454,9 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m "--account", &account, "messages", - "react", + "unreact", &group_id, &message_id, - &emoji, ]) .await { @@ -433,7 +467,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } - Effect::UnreactToMessage { + Effect::DeleteMessage { account, group_id, message_id, @@ -444,14 +478,14 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m "--account", &account, "messages", - "unreact", + "delete", &group_id, &message_id, ]) .await { - Ok(_) => Action::ReactionSuccess, - Err(e) => Action::ReactionError(e.to_string()), + Ok(_) => Action::MessageDeleted { message_id }, + Err(e) => Action::MessageDeleteError(e.to_string()), }; let _ = tx.send(Event::Action(action)); }); @@ -774,7 +808,7 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m let tx = tx.clone(); tokio::spawn(async move { let result = tokio::process::Command::new("curl") - .args(["-s", "-L", "--max-time", "10", &url]) + .args(["-s", "-L", "--fail", "--max-time", "10", &url]) .output() .await; match result { @@ -940,6 +974,117 @@ fn execute_effect(effect: Effect, tx: &mpsc::UnboundedSender, streams: &m }); } + Effect::DownloadMedia { + account, + group_id, + file_hash, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "media", + "download", + &group_id, + &file_hash, + ]) + .await + { + Ok(val) => { + let path = val + .get("file_path") + .and_then(|v| v.as_str()) + .or_else(|| val.as_str()); + match path { + Some(p) => Action::MediaDownloaded { + file_hash: file_hash.clone(), + file_path: p.to_string(), + }, + None => Action::MediaDownloadFailed { + file_hash, + error: format!("Unexpected response: {val}"), + }, + } + } + Err(e) => Action::MediaDownloadFailed { + file_hash, + error: e.to_string(), + }, + }; + let _ = tx.send(Event::Action(action)); + }); + } + + Effect::LoadMediaImage { + file_hash, + file_path, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + match tokio::fs::read(&file_path).await { + Ok(bytes) => { + let _ = + tx.send(Event::Action(Action::MediaImageLoaded { file_hash, bytes })); + } + Err(e) => { + send_log(&tx, format!("Failed to read media file: {e}")); + } + } + }); + } + + Effect::LoadMediaPopup { file_path } => { + let tx = tx.clone(); + tokio::spawn(async move { + match tokio::fs::read(&file_path).await { + Ok(bytes) => { + // Decode to get dimensions, then send bytes for popup + match image::load_from_memory(&bytes) { + Ok(img) => { + let _ = tx.send(Event::Action(Action::MediaPopupReady { + img_width: img.width(), + img_height: img.height(), + bytes, + })); + } + Err(e) => { + send_log(&tx, format!("Popup image decode failed: {e}")); + } + } + } + Err(e) => { + send_log(&tx, format!("Failed to read media file for popup: {e}")); + } + } + }); + } + + Effect::UploadMedia { + account, + group_id, + file_path, + } => { + let tx = tx.clone(); + tokio::spawn(async move { + let action = match wn::exec(&[ + "--account", + &account, + "media", + "upload", + "--send", + &group_id, + &file_path, + ]) + .await + { + Ok(_) => Action::MediaUploaded, + Err(e) => Action::MediaUploadError(e.to_string()), + }; + let _ = tx.send(Event::Action(action)); + }); + } + Effect::TailDaemonLog => { let tx = tx.clone(); tokio::spawn(async move { diff --git a/src/screen/main_screen.rs b/src/screen/main_screen.rs index ce828a2..2c7140c 100644 --- a/src/screen/main_screen.rs +++ b/src/screen/main_screen.rs @@ -5,6 +5,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, Frame, }; +use ratatui_image::StatefulImage; use crate::app::{App, LogTab, Panel}; use crate::widget::chat_list::ChatListWidget; @@ -12,7 +13,7 @@ use crate::widget::input::InputWidget; use crate::widget::message_list::MessageListWidget; use crate::widget::status_bar::StatusBarWidget; -pub fn draw(app: &App, frame: &mut Frame, area: Rect) { +pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { // Vertical: content [+ log panel] + hints + status bar let log_height = if app.show_logs { (area.height / 3).max(5) // ~1/3 of screen, minimum 5 rows @@ -67,7 +68,7 @@ fn draw_chat_list(app: &App, frame: &mut Frame, area: Rect) { /// Maximum total rows for the composer (including borders). const MAX_COMPOSER_HEIGHT: u16 = 8; -fn draw_message_panel(app: &App, frame: &mut Frame, area: Rect) { +fn draw_message_panel(app: &mut App, frame: &mut Frame, area: Rect) { // Calculate dynamic composer height based on content. // inner_width = total width minus 2 border columns let inner_width = area.width.saturating_sub(2); @@ -84,7 +85,7 @@ fn draw_message_panel(app: &App, frame: &mut Frame, area: Rect) { draw_composer(app, frame, composer_area); } -fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { +fn draw_messages(app: &mut App, frame: &mut Frame, area: Rect) { let title = if let Some(ref gid) = app.active_group_id { // Try to find the chat name from the selected chat let name = app @@ -135,14 +136,13 @@ fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { // Show selection highlight when Messages panel is focused let selected = if app.focus == Panel::Messages { - app.selected_message - .or_else(|| { - if app.messages.is_empty() { - None - } else { - Some(app.messages.len() - 1) - } - }) + app.selected_message.or_else(|| { + if app.messages.is_empty() { + None + } else { + Some(app.messages.len() - 1) + } + }) } else { None }; @@ -150,8 +150,18 @@ fn draw_messages(app: &App, frame: &mut Frame, area: Rect) { let widget = MessageListWidget::new(&app.messages, app.message_scroll) .block(block) .my_pubkey(app.account.as_deref()) - .selected(selected); + .selected(selected) + .media_downloads(&app.media_downloads) + .inline_images(&app.inline_images); + let image_rects = widget.image_positions(area); frame.render_widget(widget, area); + + // Render inline images on top of the reserved blank rows + for (hash, rect) in image_rects { + if let Some(proto) = app.inline_images.get_mut(&hash) { + frame.render_stateful_widget(StatefulImage::default(), rect, proto); + } + } } fn draw_composer(app: &App, frame: &mut Frame, area: Rect) { @@ -162,14 +172,21 @@ fn draw_composer(app: &App, frame: &mut Frame, area: Rect) { Color::DarkGray }; - let placeholder = if app.active_group_id.is_some() { + let placeholder: String = if let Some((_, ref author, ref preview)) = app.reply_to { + let truncated = if preview.chars().count() > 30 { + format!("{}...", preview.chars().take(27).collect::()) + } else { + preview.clone() + }; + format!(" Replying to {author}: \"{truncated}\" ") + } else if app.active_group_id.is_some() { if focused { - " Type a message... " + " Type a message... ".to_string() } else { - " [i] to compose " + " [i] to compose ".to_string() } } else { - "" + String::new() }; let block = Block::default() @@ -221,6 +238,9 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { key("S"), label("Settings"), sep(), + key("A"), + label("Switch account"), + sep(), key("C"), label("New identity"), sep(), @@ -240,9 +260,21 @@ fn draw_hints(app: &App, frame: &mut Frame, area: Rect) { key("r"), label("React"), sep(), + key("R"), + label("Reply"), + sep(), key("u"), label("Unreact"), sep(), + key("d"), + label("Delete"), + sep(), + key("o"), + label("Open image"), + sep(), + key("U"), + label("Upload"), + sep(), key("i"), label("Compose"), sep(), diff --git a/src/screen/profile.rs b/src/screen/profile.rs index da800c2..2f789e5 100644 --- a/src/screen/profile.rs +++ b/src/screen/profile.rs @@ -85,7 +85,7 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: Rect) { let not_set = "(not set)"; // Split info area horizontally if we have an image - let (image_area, text_area) = if has_image { + let (image_area, text_area) = if has_image && vertical[0].width >= 48 { let cols = Layout::horizontal([ Constraint::Length(20), // Image column Constraint::Fill(1), // Text fields diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..d77ad5a --- /dev/null +++ b/src/util.rs @@ -0,0 +1,31 @@ +use serde_json::Value; + +/// Extract a named field from a JSON object and convert it to a hex string. +/// Handles plain strings, `{"value":{"vec":[u8,...]}}` objects, and direct `[u8,...]` arrays. +pub fn extract_hash_hex(obj: &Value, field: &str) -> Option { + value_to_hex(obj.get(field)?) +} + +/// Convert a JSON value to a hex string. +/// Handles plain strings, `{"value":{"vec":[u8,...]}}` objects, and direct `[u8,...]` arrays. +pub fn value_to_hex(val: &Value) -> Option { + if let Some(s) = val.as_str() { + if !s.is_empty() { + return Some(s.to_string()); + } + } + let arr = val + .get("value") + .and_then(|v| v.get("vec")) + .and_then(|v| v.as_array()) + .or_else(|| val.as_array())?; + let bytes: Vec = arr + .iter() + .map(|b| b.as_u64().and_then(|n| u8::try_from(n).ok())) + .collect::>>()?; + if bytes.is_empty() { + None + } else { + Some(bytes.iter().map(|b| format!("{b:02x}")).collect()) + } +} diff --git a/src/widget/chat_list.rs b/src/widget/chat_list.rs index 83168c9..dc113c7 100644 --- a/src/widget/chat_list.rs +++ b/src/widget/chat_list.rs @@ -34,23 +34,7 @@ fn last_message(chat: &Value) -> Option<&str> { /// since CLI commands expect hex-encoded group IDs. pub fn group_id(chat: &Value) -> Option { let val = chat.get("mls_group_id").or_else(|| chat.get("group_id"))?; - if let Some(s) = val.as_str() { - return Some(s.to_string()); - } - // Handle object format: {"value": {"vec": [u8, ...]}} - let bytes = val - .get("value") - .and_then(|v| v.get("vec")) - .and_then(|v| v.as_array())?; - let hex: String = bytes - .iter() - .filter_map(|b| b.as_u64().map(|n| format!("{:02x}", n))) - .collect(); - if hex.is_empty() { - None - } else { - Some(hex) - } + crate::util::value_to_hex(val) } /// Renders the chat list sidebar. diff --git a/src/widget/message_list.rs b/src/widget/message_list.rs index 3eb704b..e0a5f2e 100644 --- a/src/widget/message_list.rs +++ b/src/widget/message_list.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use ratatui::{ buffer::Buffer, layout::Rect, @@ -8,6 +10,12 @@ use ratatui::{ use serde_json::Value; use unicode_width::UnicodeWidthStr; +use crate::app::MediaDownload; +use ratatui_image::protocol::StatefulProtocol; + +/// Height in terminal rows for inline image thumbnails. +const INLINE_IMAGE_ROWS: u16 = 8; + /// Extract display name from a message JSON value. fn author_name(msg: &Value) -> &str { msg.get("display_name") @@ -80,10 +88,147 @@ fn format_reactions(msg: &Value, indent: usize) -> Option> { Some(Line::from(spans)) } +use crate::util::extract_hash_hex; + +/// Extract display filename from an attachment. +fn attachment_filename(att: &Value) -> String { + // Try file_metadata.original_filename, then direct filename, then blossom_url basename + att.get("file_metadata") + .and_then(|m| m.get("original_filename").or_else(|| m.get("filename"))) + .and_then(|v| v.as_str()) + .or_else(|| att.get("filename").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .or_else(|| { + att.get("blossom_url") + .and_then(|v| v.as_str()) + .and_then(|url| url.rsplit('/').next()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "file".to_string()) +} + +/// Format attachment placeholder lines for a message. +/// For images with an available inline protocol, emits blank rows (the actual +/// image is rendered separately by the caller). Otherwise emits text placeholders. +fn format_attachments( + msg: &Value, + indent: usize, + media_downloads: Option<&HashMap>, + inline_images: Option<&HashMap>, +) -> Vec> { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) if !arr.is_empty() => arr, + _ => return vec![], + }; + let mut lines = Vec::new(); + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + let filename = attachment_filename(att); + let is_image = mime.starts_with("image/"); + + if !is_image { + lines.push(Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled( + format!("[file {filename}]"), + Style::default().fg(Color::DarkGray), + ), + ])); + continue; + } + + // Try original_file_hash, file_hash, encrypted_file_hash + let hash_hex = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + + // If we have an inline image, reserve blank rows (rendered later) + let has_inline = inline_images + .map(|m| m.contains_key(&hash_hex)) + .unwrap_or(false); + if has_inline { + for _ in 0..INLINE_IMAGE_ROWS { + lines.push(Line::raw("")); + } + continue; + } + + let status = media_downloads.and_then(|m| m.get(&hash_hex)); + let label = match status { + Some(MediaDownload::Downloading) => format!("[downloading {filename}...]"), + Some(MediaDownload::Downloaded(_)) => format!("[loading {filename}...]"), + Some(MediaDownload::Failed(err)) => format!("[{filename} failed: {err}]"), + None => format!("[img {filename}]"), + }; + let color = match status { + Some(MediaDownload::Downloading) => Color::Yellow, + Some(MediaDownload::Downloaded(_)) => Color::Cyan, + Some(MediaDownload::Failed(_)) => Color::Red, + None => Color::DarkGray, + }; + + lines.push(Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled(label, Style::default().fg(color)), + ])); + } + lines +} + +/// Check if a message is a reply and return the replied-to event ID. +fn reply_to_id(msg: &Value) -> Option<&str> { + let reply_id = msg.get("reply_to").and_then(|v| v.as_str())?; + if reply_id.is_empty() { + return None; + } + Some(reply_id) +} + +/// Format a reply indicator line by looking up the parent message. +fn format_reply_line(reply_id: &str, messages: &[Value], indent: usize) -> Line<'static> { + let parent = messages + .iter() + .find(|m| m.get("id").and_then(|v| v.as_str()) == Some(reply_id)); + + let label = if let Some(parent) = parent { + let name = author_name(parent); + let preview: String = content(parent).chars().take(30).collect(); + if preview.chars().count() >= 30 { + format!("reply to {name}: \"{preview}...\"") + } else { + format!("reply to {name}: \"{preview}\"") + } + } else { + let short_id = if reply_id.len() > 12 { + format!("{}...", &reply_id[..12]) + } else { + reply_id.to_string() + }; + format!("reply to {short_id}") + }; + + Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled( + format!("\u{21a9} {label}"), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + ]) +} + /// Build the display lines for a message. /// All messages use the same layout: `[HH:MM] author: content` /// Own messages are distinguished by green author color. -fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { +fn format_message( + msg: &Value, + my_pubkey: Option<&str>, + media_downloads: Option<&HashMap>, + inline_images: Option<&HashMap>, + all_messages: &[Value], +) -> Vec> { let ts = timestamp(msg); let author = author_name(msg); let text = content(msg); @@ -105,6 +250,12 @@ fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { let content_lines: Vec<&str> = text.split('\n').collect(); let mut lines = Vec::new(); + + // Reply indicator (before content) + if let Some(reply_id) = reply_to_id(msg) { + lines.push(format_reply_line(reply_id, all_messages, indent)); + } + for (i, line_text) in content_lines.iter().enumerate() { if i == 0 { lines.push(Line::from(vec![ @@ -124,6 +275,13 @@ fn format_message(msg: &Value, my_pubkey: Option<&str>) -> Vec> { lines.push(reaction_line); } + lines.extend(format_attachments( + msg, + indent, + media_downloads, + inline_images, + )); + lines } @@ -137,7 +295,44 @@ fn has_reactions(msg: &Value) -> bool { /// Estimate the rendered height of a message at a given terminal width. /// Accounts for explicit newlines, line wrapping, and a reaction line if present. -fn message_height(msg: &Value, width: usize) -> usize { +/// Calculate total rows needed for attachments, accounting for inline images. +fn attachment_rows( + msg: &Value, + inline_images: Option<&HashMap>, +) -> usize { + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return 0, + }; + let mut rows = 0; + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + let is_image = mime.starts_with("image/"); + if is_image { + let hash = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + let has_inline = inline_images + .map(|m| m.contains_key(&hash)) + .unwrap_or(false); + rows += if has_inline { + INLINE_IMAGE_ROWS as usize + } else { + 1 + }; + } else { + rows += 1; // text placeholder for non-image + } + } + rows +} + +fn message_height_with_images( + msg: &Value, + width: usize, + inline_images: Option<&HashMap>, +) -> usize { let ts = timestamp(msg); let author = author_name(msg); let text = content(msg); @@ -146,6 +341,11 @@ fn message_height(msg: &Value, width: usize) -> usize { let content_lines: Vec<&str> = text.split('\n').collect(); let mut total_rows = 0; + + if reply_to_id(msg).is_some() { + total_rows += 1; + } + for line_text in &content_lines { total_rows += wrapped_line_count(prefix_width + line_text.width(), width); } @@ -154,9 +354,16 @@ fn message_height(msg: &Value, width: usize) -> usize { total_rows += 1; } + total_rows += attachment_rows(msg, inline_images); + total_rows.max(1) } +#[cfg(test)] +fn message_height(msg: &Value, width: usize) -> usize { + message_height_with_images(msg, width, None) +} + /// Renders the message list. /// `scroll_from_bottom` is how many messages to scroll up from the bottom (0 = at bottom). pub struct MessageListWidget<'a> { @@ -166,6 +373,8 @@ pub struct MessageListWidget<'a> { my_pubkey: Option<&'a str>, /// If set, highlight this message index (absolute index into messages slice). selected: Option, + media_downloads: Option<&'a HashMap>, + inline_images: Option<&'a HashMap>, } impl<'a> MessageListWidget<'a> { @@ -176,6 +385,8 @@ impl<'a> MessageListWidget<'a> { block: None, my_pubkey: None, selected: None, + media_downloads: None, + inline_images: None, } } @@ -193,6 +404,129 @@ impl<'a> MessageListWidget<'a> { self.selected = selected; self } + + pub fn media_downloads(mut self, downloads: &'a HashMap) -> Self { + self.media_downloads = Some(downloads); + self + } + + pub fn inline_images(mut self, images: &'a HashMap) -> Self { + self.inline_images = Some(images); + self + } +} + +impl MessageListWidget<'_> { + /// Compute which messages are visible and their Y positions. + /// Returns (visible_msg_indices, inner_area). + fn compute_visible(&self, area: Rect) -> (Vec<(usize, usize)>, Rect) { + let inner = if let Some(block) = &self.block { + block.inner(area) + } else { + area + }; + + if inner.height == 0 || inner.width == 0 || self.messages.is_empty() { + return (vec![], inner); + } + + 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 mut visible_msgs: Vec = Vec::new(); + let mut used_rows = 0; + + let end = total.saturating_sub(skip_messages); + for i in (0..end).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); + } + visible_msgs.reverse(); + + // Compute Y positions + 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); + y += h; + } + + (result, inner) + } + + /// 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)> { + let (visible, inner) = self.compute_visible(area); + let width = inner.width as usize; + let mut positions = Vec::new(); + + for (idx, base_y) in visible { + let msg = &self.messages[idx]; + let attachments = match msg.get("media_attachments").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => continue, + }; + + // Compute the Y offset where attachments start (after reply + content + reactions) + let text = content(msg); + let ts = timestamp(msg); + let author = author_name(msg); + let ts_prefix = format!("[{ts}] "); + let ts_cols = ts_prefix.width() as u16; + let prefix_width = ts_prefix.width() + format!("{author}: ").width(); + let content_lines: Vec<&str> = text.split('\n').collect(); + let mut row_offset = 0usize; + if reply_to_id(msg).is_some() { + row_offset += 1; + } + for line_text in &content_lines { + row_offset += wrapped_line_count(prefix_width + line_text.width(), width); + } + if has_reactions(msg) { + row_offset += 1; + } + + for att in attachments { + let mime = att.get("mime_type").and_then(|v| v.as_str()).unwrap_or(""); + if !mime.starts_with("image/") { + row_offset += 1; // non-image placeholder line + continue; + } + let hash = extract_hash_hex(att, "original_file_hash") + .or_else(|| extract_hash_hex(att, "file_hash")) + .or_else(|| extract_hash_hex(att, "encrypted_file_hash")) + .unwrap_or_default(); + + let has_inline = self + .inline_images + .map(|m| m.contains_key(&hash)) + .unwrap_or(false); + + if has_inline { + let img_y = base_y + row_offset; + let img_x = inner.x + ts_cols; + let img_w = inner.width.saturating_sub(ts_cols); + positions.push(( + hash, + Rect::new(img_x, img_y as u16, img_w, INLINE_IMAGE_ROWS), + )); + row_offset += INLINE_IMAGE_ROWS as usize; + } else { + row_offset += 1; // text placeholder + } + } + } + + positions + } } impl Widget for MessageListWidget<'_> { @@ -219,19 +553,15 @@ impl Widget for MessageListWidget<'_> { let visible_height = inner.height as usize; let width = inner.width as usize; - - // Walk backwards from the end to find which messages fit in the viewport, - // accounting for wrapped line heights and scroll offset. let total = self.messages.len(); let skip_messages = self.scroll_from_bottom.min(total); - // Collect messages that fit in the visible area, walking from bottom to top - let mut visible_msgs: Vec = Vec::new(); // indices into self.messages + let mut visible_msgs: Vec = Vec::new(); let mut used_rows = 0; let end = total.saturating_sub(skip_messages); for i in (0..end).rev() { - let h = message_height(&self.messages[i], width); + let h = message_height_with_images(&self.messages[i], width, self.inline_images); if used_rows + h > visible_height { break; } @@ -244,8 +574,14 @@ impl Widget for MessageListWidget<'_> { let mut y = inner.y; for &idx in &visible_msgs { let msg = &self.messages[idx]; - let lines = format_message(msg, self.my_pubkey); - let h = message_height(msg, width); + let lines = format_message( + msg, + self.my_pubkey, + self.media_downloads, + self.inline_images, + self.messages, + ); + let h = message_height_with_images(msg, width, self.inline_images); let is_selected = self.selected == Some(idx); let msg_area = Rect::new(inner.x, y, inner.width, h as u16); @@ -255,11 +591,14 @@ impl Widget for MessageListWidget<'_> { .render(msg_area, buf); if is_selected { - // Apply background highlight after text rendering for row in msg_area.y..msg_area.y + msg_area.height { for col in msg_area.x..msg_area.x + msg_area.width { if let Some(cell) = buf.cell_mut((col, row)) { cell.set_bg(Color::DarkGray); + // Ensure fg contrast: DarkGray-on-DarkGray is invisible + if cell.fg == Color::DarkGray { + cell.set_fg(Color::Gray); + } } } } @@ -393,7 +732,7 @@ mod tests { "author": "alice", "created_at_local": "2026-01-01 10:00:00" }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2, "Should produce 2 Line entries"); } @@ -401,7 +740,7 @@ mod tests { fn format_reactions_empty() { let msg = json!({"content": "hi", "author": "a", "created_at_local": "2026-01-01 10:00:00"}); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 1, "No reactions = no extra line"); } @@ -417,7 +756,7 @@ mod tests { } } }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2, "Should have content + reaction line"); let reaction_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(reaction_text.contains("👍"), "Should contain the emoji"); @@ -438,7 +777,7 @@ mod tests { } } }); - let lines = format_message(&msg, None); + let lines = format_message(&msg, None, None, None, &[]); assert_eq!(lines.len(), 2); let reaction_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(reaction_text.contains("👍")); @@ -475,10 +814,190 @@ mod tests { "author": "me", "created_at_local": "2026-01-01 10:00:00" }); - let lines = format_message(&msg, Some("me")); + let lines = format_message(&msg, Some("me"), None, None, &[]); // Own messages use same layout: [HH:MM] author: content assert_eq!(lines.len(), 1); // First span is timestamp, second is author, third is content assert_eq!(lines[0].spans.len(), 3); } + + #[test] + fn format_message_includes_attachment_lines() { + let downloads = HashMap::from([( + "hash1".to_string(), + MediaDownload::Downloaded("/tmp/photo.png".into()), + )]); + let msg = json!({ + "content": "check this", + "author": "alice", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "image/png", + "filename": "photo.png" + } + ] + }); + let lines = format_message(&msg, None, Some(&downloads), None, &[]); + // 1 content line + 1 attachment line + assert_eq!(lines.len(), 2); + let att_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + att_text.contains("[loading photo.png...]"), + "got: {att_text}" + ); + } + + #[test] + fn format_message_shows_downloading_status() { + let downloads = HashMap::from([("hash1".to_string(), MediaDownload::Downloading)]); + let msg = json!({ + "content": "wait", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "image/jpeg", + "filename": "img.jpg" + } + ] + }); + let lines = format_message(&msg, None, Some(&downloads), None, &[]); + let att_text: String = lines + .last() + .unwrap() + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(att_text.contains("downloading"), "got: {att_text}"); + } + + #[test] + fn format_message_shows_non_image_as_file() { + let msg = json!({ + "content": "doc", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "original_file_hash": "hash1", + "mime_type": "application/pdf", + "filename": "doc.pdf" + } + ] + }); + let lines = format_message(&msg, None, None, None, &[]); + let att_text: String = lines + .last() + .unwrap() + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(att_text.contains("[file doc.pdf]"), "got: {att_text}"); + } + + #[test] + fn message_height_includes_attachments() { + let msg = json!({ + "content": "img", + "author": "a", + "created_at_local": "2026-01-01 10:00:00", + "media_attachments": [ + { + "file_hash": "h1", + "mime_type": "image/png", + "filename": "a.png" + }, + { + "file_hash": "h2", + "mime_type": "image/gif", + "filename": "b.gif" + } + ] + }); + // 1 content line + 2 attachment lines + assert_eq!(message_height(&msg, 80), 3); + } + + #[test] + fn format_message_reply_shows_indicator() { + let parent = json!({ + "id": "parent1", + "content": "original message", + "author": "alice", + "display_name": "Alice", + "created_at_local": "2026-01-01 09:00:00" + }); + let reply = json!({ + "id": "reply1", + "content": "my reply", + "author": "bob", + "reply_to": "parent1", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + let messages = vec![parent, reply.clone()]; + let lines = format_message(&reply, None, None, None, &messages); + // Should have reply indicator line + content line = 2 lines + assert_eq!(lines.len(), 2); + let reply_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + reply_text.contains("Alice"), + "should show parent author: {reply_text}" + ); + assert!( + reply_text.contains("original message"), + "should show parent content: {reply_text}" + ); + } + + #[test] + fn format_message_reply_fallback_when_parent_missing() { + let reply = json!({ + "id": "reply1", + "content": "orphaned reply", + "author": "bob", + "reply_to": "unknown_event_id_12345", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + let lines = format_message(&reply, None, None, None, &[]); + assert_eq!(lines.len(), 2); + let reply_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + reply_text.contains("unknown_even"), + "should show truncated event id: {reply_text}" + ); + } + + #[test] + fn message_height_includes_reply_line() { + let msg = json!({ + "content": "reply text", + "author": "a", + "reply_to": "parent1", + "is_reply": true, + "created_at_local": "2026-01-01 10:00:00" + }); + // 1 reply indicator + 1 content = 2 + assert_eq!(message_height(&msg, 80), 2); + } + + #[test] + fn non_reply_no_indicator() { + let msg = json!({ + "content": "normal", + "author": "a", + "reply_to": null, + "is_reply": false, + "created_at_local": "2026-01-01 10:00:00" + }); + let lines = format_message(&msg, None, None, None, &[]); + assert_eq!(lines.len(), 1); + assert_eq!(message_height(&msg, 80), 1); + } }