From d90934f2015dec5d22e327a519a9f43155dce07d Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Tue, 18 Aug 2026 16:29:35 -0600 Subject: [PATCH 01/13] feat(ui): add order book filter state --- src/ui/app_state.rs | 13 ++- src/ui/helpers/mod.rs | 3 +- src/ui/helpers/order_selection.rs | 163 ++++++++++++++++++++++++++++-- src/ui/orders.rs | 156 ++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 11 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 12211fbf..bfc998ab 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -18,7 +18,8 @@ use crate::ui::helpers::OrderChatListItem; use crate::ui::navigation::{AdminTab, Tab, UserRole}; use crate::ui::orders::{ BuyerInvoicePreference, FormState, InvoiceInputState, KeyInputState, MessageNotification, - MessageViewState, OperationResult, OrderChatStaticHeader, OrderMessage, RatingOrderState, + MessageViewState, OperationResult, OrderBookFilterState, OrderBookFilters, + OrderChatStaticHeader, OrderMessage, RatingOrderState, }; use crate::ui::user_state::UserMode; use crate::util::{transport_from_instance, MostroInstanceInfo}; @@ -60,11 +61,13 @@ pub enum UiMode { AddCurrency(KeyInputState), ConfirmCurrency(String, bool), // (currency_string, selected_button: true=Yes, false=No) ConfirmClearCurrencies(bool), // (selected_button: true=Yes, false=No) + /// Orders tab local filters. Enter applies, Esc cancels. + OrderFilters(OrderBookFilterState), ConfirmDeleteHistoryOrder(uuid::Uuid, bool), // (order_id, selected_button) - ConfirmBulkDeleteHistory(bool), // (selected_button) + ConfirmBulkDeleteHistory(bool), // (selected_button) /// User Settings: ask Mostro to restore this identity's orders and disputes. ConfirmRestoreSession(bool), // (selected_button: true=Yes, false=No) - ConfirmExit(bool), // (selected_button: true=Yes, false=No) + ConfirmExit(bool), // (selected_button: true=Yes, false=No) // Generate new keys flow (Settings tab) ConfirmGenerateNewKeys(bool), // (selected_button: true=Yes, false=No) @@ -161,6 +164,7 @@ impl Clone for UiMode { UiMode::ConfirmCurrency(currency.clone(), *selected) } UiMode::ConfirmClearCurrencies(selected) => UiMode::ConfirmClearCurrencies(*selected), + UiMode::OrderFilters(state) => UiMode::OrderFilters(state.clone()), UiMode::ConfirmDeleteHistoryOrder(order_id, selected) => { UiMode::ConfirmDeleteHistoryOrder(*order_id, *selected) } @@ -291,6 +295,8 @@ pub struct AppState { pub pending_admin_disputes_reload: bool, /// Cached copy of currencies filter from settings (used for UI-side filtering). pub currencies_filter: Vec, + /// Orders-tab local filters, applied after the settings currency filter. + pub order_filters: OrderBookFilters, /// Cached Mostro instance info (kind 38385), if available. /// Populated only from client-authenticated fetches; apply via [`Self::set_mostro_info`]. pub mostro_info: Option, @@ -391,6 +397,7 @@ impl AppState { admin_keys: None, pending_admin_disputes_reload: false, currencies_filter: Vec::new(), + order_filters: OrderBookFilters::default(), mostro_info: None, transport: Transport::default(), offline_overlay_message: None, diff --git a/src/ui/helpers/mod.rs b/src/ui/helpers/mod.rs index 148461ce..86be58ba 100644 --- a/src/ui/helpers/mod.rs +++ b/src/ui/helpers/mod.rs @@ -58,7 +58,8 @@ pub use order_chat_projection::{ }; pub use order_selection::{ get_filtered_book_orders, move_book_order_selection, order_passes_currency_filter, - selected_book_display_idx, selected_filtered_book_order, + order_passes_order_filters, order_passes_order_filters_at, selected_book_display_idx, + selected_filtered_book_order, }; pub use startup::{ active_peer_chat_order_ids_for_restore, admin_chat_keys_clone_for_role, diff --git a/src/ui/helpers/order_selection.rs b/src/ui/helpers/order_selection.rs index 8f82a06e..f94754cf 100644 --- a/src/ui/helpers/order_selection.rs +++ b/src/ui/helpers/order_selection.rs @@ -6,9 +6,10 @@ use std::collections::HashSet; -use mostro_core::prelude::SmallOrder; +use mostro_core::prelude::{Kind, SmallOrder}; use uuid::Uuid; +use crate::ui::orders::{OrderBookFilters, OrderBookKindFilter}; use crate::ui::AppState; /// Whether `order` passes the active currency filter (empty filter = all pass). @@ -20,15 +21,103 @@ pub fn order_passes_currency_filter(order: &SmallOrder, currencies_filter: &[Str filter_set.contains(&order.fiat_code.to_uppercase()) } -/// Currency-filtered book rows as `(original_index, order)` pairs. +fn parse_i64_filter(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn parse_days_filter(value: &str) -> Option { + value.trim().parse::().ok().filter(|v| *v > 0) +} + +fn fiat_amount_bounds(order: &SmallOrder) -> (i64, i64) { + match (order.min_amount, order.max_amount) { + (Some(min), Some(max)) => (min, max), + (Some(min), None) => (min, i64::MAX), + (None, Some(max)) => (0, max), + (None, None) => (order.fiat_amount, order.fiat_amount), + } +} + +/// Whether `order` passes local Orders-tab filters. +pub fn order_passes_order_filters_at( + order: &SmallOrder, + filters: &OrderBookFilters, + now: i64, +) -> bool { + match filters.kind { + OrderBookKindFilter::Any => {} + OrderBookKindFilter::Buy if !matches!(order.kind, Some(Kind::Buy)) => return false, + OrderBookKindFilter::Sell if !matches!(order.kind, Some(Kind::Sell)) => return false, + _ => {} + } + + let fiat = filters.fiat_code.trim(); + if !fiat.is_empty() && !order.fiat_code.eq_ignore_ascii_case(fiat) { + return false; + } + + let payment = filters.payment_method.trim().to_ascii_lowercase(); + if !payment.is_empty() + && !order + .payment_method + .to_ascii_lowercase() + .contains(payment.as_str()) + { + return false; + } + + let (order_min, order_max) = fiat_amount_bounds(order); + if let Some(min) = parse_i64_filter(&filters.fiat_amount_min) { + if order_max < min { + return false; + } + } + if let Some(max) = parse_i64_filter(&filters.fiat_amount_max) { + if order_min > max { + return false; + } + } + + if let Some(min) = parse_i64_filter(&filters.premium_min) { + if order.premium < min { + return false; + } + } + if let Some(max) = parse_i64_filter(&filters.premium_max) { + if order.premium > max { + return false; + } + } + + if let Some(days) = parse_days_filter(&filters.created_within_days) { + let Some(created_at) = order.created_at else { + return false; + }; + let cutoff = now.saturating_sub(days.saturating_mul(86_400)); + if created_at < cutoff { + return false; + } + } + + true +} + +/// Whether `order` passes local Orders-tab filters using the current wall clock. +pub fn order_passes_order_filters(order: &SmallOrder, filters: &OrderBookFilters) -> bool { + order_passes_order_filters_at(order, filters, chrono::Utc::now().timestamp()) +} + +/// Filtered book rows as `(original_index, order)` pairs. pub fn get_filtered_book_orders( orders: &[SmallOrder], currencies_filter: &[String], + order_filters: &OrderBookFilters, ) -> Vec<(usize, SmallOrder)> { orders .iter() .enumerate() .filter(|(_, o)| order_passes_currency_filter(o, currencies_filter)) + .filter(|(_, o)| order_passes_order_filters(o, order_filters)) .map(|(i, o)| (i, o.clone())) .collect() } @@ -56,7 +145,7 @@ pub fn selected_book_display_idx( /// Resolves `selected_order_id` against the currency-filtered book so Enter/take /// always acts on the highlighted row — never on a row hidden by the filter. pub fn selected_filtered_book_order(app: &AppState, orders: &[SmallOrder]) -> Option { - let mut filtered = get_filtered_book_orders(orders, &app.currencies_filter); + let mut filtered = get_filtered_book_orders(orders, &app.currencies_filter, &app.order_filters); let idx = selected_book_display_idx(app.selected_order_id, &filtered)?; Some(filtered.swap_remove(idx).1) } @@ -64,7 +153,7 @@ pub fn selected_filtered_book_order(app: &AppState, orders: &[SmallOrder]) -> Op /// Move Orders-tab selection `delta` rows within the filtered book, clamping at /// both ends, and store the landing order's id (when present). pub fn move_book_order_selection(app: &mut AppState, orders: &[SmallOrder], delta: isize) { - let filtered = get_filtered_book_orders(orders, &app.currencies_filter); + let filtered = get_filtered_book_orders(orders, &app.currencies_filter, &app.order_filters); let Some(idx) = selected_book_display_idx(app.selected_order_id, &filtered) else { app.selected_order_id = None; return; @@ -78,6 +167,7 @@ pub fn move_book_order_selection(app: &mut AppState, orders: &[SmallOrder], delt #[cfg(test)] mod tests { use super::*; + use crate::ui::orders::{OrderBookFilters, OrderBookKindFilter}; use crate::ui::UserRole; use mostro_core::prelude::Kind; @@ -93,12 +183,19 @@ mod tests { } } + fn sell_order(id: Uuid, fiat: &str, payment: &str) -> SmallOrder { + SmallOrder { + kind: Some(Kind::Sell), + ..order(id, fiat, payment) + } + } + #[test] fn empty_currency_filter_keeps_all_orders() { let a = Uuid::new_v4(); let b = Uuid::new_v4(); let orders = vec![order(a, "USD", "sepa"), order(b, "EUR", "sepa")]; - let filtered = get_filtered_book_orders(&orders, &[]); + let filtered = get_filtered_book_orders(&orders, &[], &OrderBookFilters::default()); assert_eq!(filtered.len(), 2); } @@ -107,11 +204,62 @@ mod tests { let a = Uuid::new_v4(); let b = Uuid::new_v4(); let orders = vec![order(a, "USD", "sepa"), order(b, "EUR", "sepa")]; - let filtered = get_filtered_book_orders(&orders, &["EUR".to_string()]); + let filtered = + get_filtered_book_orders(&orders, &["EUR".to_string()], &OrderBookFilters::default()); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].1.id, Some(b)); } + #[test] + fn local_filters_match_kind_fiat_and_payment_method() { + let buy = Uuid::new_v4(); + let sell = Uuid::new_v4(); + let orders = vec![ + order(buy, "USD", "Cash App"), + sell_order(sell, "EUR", "SEPA"), + ]; + let filters = OrderBookFilters { + kind: OrderBookKindFilter::Sell, + fiat_code: "eur".to_string(), + payment_method: "sep".to_string(), + ..Default::default() + }; + + let filtered = get_filtered_book_orders(&orders, &[], &filters); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].1.id, Some(sell)); + } + + #[test] + fn local_filters_match_numeric_ranges_and_created_at() { + let fresh = Uuid::new_v4(); + let old = Uuid::new_v4(); + let now = 2_000_000; + let fresh_order = SmallOrder { + premium: 5, + fiat_amount: 150, + created_at: Some(now - 3_600), + ..order(fresh, "USD", "cash") + }; + let old_order = SmallOrder { + premium: -2, + fiat_amount: 300, + created_at: Some(now - 10 * 86_400), + ..order(old, "USD", "cash") + }; + let filters = OrderBookFilters { + fiat_amount_min: "100".to_string(), + fiat_amount_max: "200".to_string(), + premium_min: "0".to_string(), + premium_max: "10".to_string(), + created_within_days: "1".to_string(), + ..Default::default() + }; + + assert!(order_passes_order_filters_at(&fresh_order, &filters, now)); + assert!(!order_passes_order_filters_at(&old_order, &filters, now)); + } + /// Regression for the highlight/Enter mismatch: after a currency filter hides /// the previously selected order, resolution must fall back to the first /// *visible* row — the same one the table highlights — never the hidden id. @@ -128,7 +276,8 @@ mod tests { app.selected_order_id = Some(usd_id); app.currencies_filter = vec!["EUR".to_string()]; - let filtered = get_filtered_book_orders(&orders, &app.currencies_filter); + let filtered = + get_filtered_book_orders(&orders, &app.currencies_filter, &app.order_filters); assert_eq!( selected_book_display_idx(app.selected_order_id, &filtered), Some(0), diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 905154a1..61f60c38 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -13,6 +13,162 @@ use crate::ui::PRIMARY_COLOR; pub use crate::ui::constants::StepLabel; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OrderBookKindFilter { + #[default] + Any, + Buy, + Sell, +} + +impl OrderBookKindFilter { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Any => "Any", + Self::Buy => "Buy", + Self::Sell => "Sell", + } + } + + pub fn cycle(&mut self) { + *self = match self { + Self::Any => Self::Buy, + Self::Buy => Self::Sell, + Self::Sell => Self::Any, + }; + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OrderBookFilterField { + #[default] + Kind, + FiatCurrency, + FiatAmountMin, + FiatAmountMax, + PremiumMin, + PremiumMax, + PaymentMethod, + CreatedWithinDays, +} + +impl OrderBookFilterField { + pub const ALL: [Self; 8] = [ + Self::Kind, + Self::FiatCurrency, + Self::FiatAmountMin, + Self::FiatAmountMax, + Self::PremiumMin, + Self::PremiumMax, + Self::PaymentMethod, + Self::CreatedWithinDays, + ]; + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Kind => "Buy/Sell", + Self::FiatCurrency => "Fiat currency", + Self::FiatAmountMin => "Fiat amount min", + Self::FiatAmountMax => "Fiat amount max", + Self::PremiumMin => "Premium min %", + Self::PremiumMax => "Premium max %", + Self::PaymentMethod => "Payment method", + Self::CreatedWithinDays => "Created within days", + } + } + + #[must_use] + pub fn next(self) -> Self { + let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0); + Self::ALL[(idx + 1) % Self::ALL.len()] + } + + #[must_use] + pub fn prev(self) -> Self { + let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0); + Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OrderBookFilters { + pub kind: OrderBookKindFilter, + pub fiat_code: String, + pub fiat_amount_min: String, + pub fiat_amount_max: String, + pub premium_min: String, + pub premium_max: String, + pub payment_method: String, + pub created_within_days: String, +} + +impl OrderBookFilters { + #[must_use] + pub fn has_active_filters(&self) -> bool { + self.kind != OrderBookKindFilter::Any + || !self.fiat_code.trim().is_empty() + || !self.fiat_amount_min.trim().is_empty() + || !self.fiat_amount_max.trim().is_empty() + || !self.premium_min.trim().is_empty() + || !self.premium_max.trim().is_empty() + || !self.payment_method.trim().is_empty() + || !self.created_within_days.trim().is_empty() + } + + #[must_use] + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.kind != OrderBookKindFilter::Any { + parts.push(format!("kind={}", self.kind.label())); + } + if !self.fiat_code.trim().is_empty() { + parts.push(format!("fiat={}", self.fiat_code.trim().to_uppercase())); + } + if !self.fiat_amount_min.trim().is_empty() || !self.fiat_amount_max.trim().is_empty() { + parts.push(format!( + "fiat amount={}..{}", + empty_as_any(&self.fiat_amount_min), + empty_as_any(&self.fiat_amount_max) + )); + } + if !self.premium_min.trim().is_empty() || !self.premium_max.trim().is_empty() { + parts.push(format!( + "premium={}..{}%", + empty_as_any(&self.premium_min), + empty_as_any(&self.premium_max) + )); + } + if !self.payment_method.trim().is_empty() { + parts.push(format!("payment~{}", self.payment_method.trim())); + } + if !self.created_within_days.trim().is_empty() { + parts.push(format!("created<={}d", self.created_within_days.trim())); + } + if parts.is_empty() { + "none".to_string() + } else { + parts.join(" | ") + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OrderBookFilterState { + pub filters: OrderBookFilters, + pub focused: OrderBookFilterField, +} + +fn empty_as_any(value: &str) -> &str { + let trimmed = value.trim(); + if trimmed.is_empty() { + "*" + } else { + trimmed + } +} + /// Stable My Trades header fields for one trade (maker publish or taker take). Not updated by later DMs. #[derive(Clone, Debug)] pub struct OrderChatStaticHeader { From 02c905b8ee2ac01f6e3d7fb8c57b631711b11625 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Tue, 18 Aug 2026 16:30:52 -0600 Subject: [PATCH 02/13] feat(ui): add orders filter controls --- src/ui/draw.rs | 3 + src/ui/key_handler/enter_handlers.rs | 4 + src/ui/key_handler/mod.rs | 110 +++++++++++++++++++++++++++ src/ui/key_handler/navigation.rs | 2 + 4 files changed, 119 insertions(+) diff --git a/src/ui/draw.rs b/src/ui/draw.rs index 8e934b05..126dcccb 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -399,6 +399,9 @@ No: paste BOLT11 or Lightning address manually." Some("Are you sure you want to clear all currencies filters?"), ); } + if let UiMode::OrderFilters(state) = &app.mode { + tabs::orders_tab::render_order_filter_popup(f, state); + } if let UiMode::ConfirmDeleteHistoryOrder(order_id, selected_button) = &app.mode { admin_key_confirm::render_admin_key_confirm_with_message( f, diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 4b1524c7..a11878a6 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -543,6 +543,10 @@ pub fn handle_enter_key(app: &mut AppState, ctx: &super::EnterKeyContext<'_>) -> // Close help / settings reference (mode restored in key_handler/mod.rs) true } + UiMode::OrderFilters(state) => { + app.mode = UiMode::OrderFilters(state); + true + } UiMode::SaveAttachmentPopup(_) => { // Up/Down/Enter/Esc handled in key_handler/mod.rs app.mode = UiMode::AdminMode(AdminMode::ManagingDispute); diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 5a7e2ab7..285fe863 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -21,6 +21,7 @@ use crate::ui::{ active_order_chat_list_snapshot, get_order_attachment_messages, get_visible_attachment_messages, is_dispute_finalized, selected_filtered_dispute, }, + orders::{OrderBookFilterField, OrderBookFilterState, OrderBookFilters}, send_attachment_picker::{ close_user_send_attachment_picker, explorer_selection_is_sendable_file, open_user_send_attachment_picker, @@ -651,6 +652,77 @@ fn update_invoice_notification_action_selection( } } +fn active_order_filter_input(state: &mut OrderBookFilterState) -> Option<&mut String> { + match state.focused { + OrderBookFilterField::Kind => None, + OrderBookFilterField::FiatCurrency => Some(&mut state.filters.fiat_code), + OrderBookFilterField::FiatAmountMin => Some(&mut state.filters.fiat_amount_min), + OrderBookFilterField::FiatAmountMax => Some(&mut state.filters.fiat_amount_max), + OrderBookFilterField::PremiumMin => Some(&mut state.filters.premium_min), + OrderBookFilterField::PremiumMax => Some(&mut state.filters.premium_max), + OrderBookFilterField::PaymentMethod => Some(&mut state.filters.payment_method), + OrderBookFilterField::CreatedWithinDays => Some(&mut state.filters.created_within_days), + } +} + +fn handle_order_filter_popup_key(app: &mut AppState, code: KeyCode, key_event: &KeyEvent) -> bool { + let UiMode::OrderFilters(mut state) = app.mode.clone() else { + return false; + }; + + match code { + KeyCode::Esc => { + app.mode = UiMode::UserMode(UserMode::Normal); + } + KeyCode::Enter => { + app.order_filters = state.filters; + app.selected_order_id = None; + app.orders_table_state = ratatui::widgets::TableState::default(); + app.mode = UiMode::UserMode(UserMode::Normal); + } + KeyCode::Up => { + state.focused = state.focused.prev(); + app.mode = UiMode::OrderFilters(state); + } + KeyCode::Down | KeyCode::Tab => { + state.focused = state.focused.next(); + app.mode = UiMode::OrderFilters(state); + } + KeyCode::BackTab => { + state.focused = state.focused.prev(); + app.mode = UiMode::OrderFilters(state); + } + KeyCode::Char(' ') if state.focused == OrderBookFilterField::Kind => { + state.filters.kind.cycle(); + app.mode = UiMode::OrderFilters(state); + } + KeyCode::Char('x') | KeyCode::Char('X') + if key_event.modifiers.contains(KeyModifiers::CONTROL) => + { + state.filters = OrderBookFilters::default(); + app.mode = UiMode::OrderFilters(state); + } + KeyCode::Backspace => { + if let Some(input) = active_order_filter_input(&mut state) { + input.pop(); + } + app.mode = UiMode::OrderFilters(state); + } + KeyCode::Char(c) => { + if let Some(input) = active_order_filter_input(&mut state) { + if !c.is_control() { + input.push(c); + } + } + app.mode = UiMode::OrderFilters(state); + } + _ => { + app.mode = UiMode::OrderFilters(state); + } + } + true +} + #[allow(clippy::too_many_arguments)] /// Main key event handler - dispatches to appropriate handlers pub fn handle_key_event( @@ -679,6 +751,10 @@ pub fn handle_key_event( // Clear transient attachment toast on any key press app.attachment_toast = None; + if matches!(app.mode, UiMode::OrderFilters(_)) { + return Some(handle_order_filter_popup_key(app, code, &key_event)); + } + // Help popup (Ctrl+H): close on Esc, Enter, or Ctrl+H; restore previous mode so input state is preserved if let UiMode::HelpPopup(_, ref previous_mode) = &app.mode { if (key_event.modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('h')) @@ -1446,6 +1522,40 @@ pub fn handle_key_event( } } + if let Tab::User(UserTab::Orders) = app.active_tab { + let has_shift = key_event + .modifiers + .contains(crossterm::event::KeyModifiers::SHIFT); + if has_shift { + match code { + KeyCode::Char('f') | KeyCode::Char('F') => { + if matches!( + app.mode, + UiMode::Normal | UiMode::UserMode(UserMode::Normal) + ) { + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: app.order_filters.clone(), + focused: OrderBookFilterField::Kind, + }); + return Some(true); + } + } + KeyCode::Char('x') | KeyCode::Char('X') => { + if matches!( + app.mode, + UiMode::Normal | UiMode::UserMode(UserMode::Normal) + ) { + app.order_filters = OrderBookFilters::default(); + app.selected_order_id = None; + app.orders_table_state = ratatui::widgets::TableState::default(); + return Some(true); + } + } + _ => {} + } + } + } + // Check if we're in admin chat input mode FIRST - this takes priority over all other key handling // (except invoice and key input which are handled earlier) // Note: Shift+F and Shift+I are handled before this, so they won't be intercepted diff --git a/src/ui/key_handler/navigation.rs b/src/ui/key_handler/navigation.rs index a3be17ed..cbe41743 100644 --- a/src/ui/key_handler/navigation.rs +++ b/src/ui/key_handler/navigation.rs @@ -288,6 +288,7 @@ fn handle_up_key( | UiMode::UserMode(UserMode::WaitingAddInvoice) | UiMode::HelpPopup(..) | UiMode::SettingsInstructionsPopup(..) + | UiMode::OrderFilters(_) | UiMode::OperationResult(_) | UiMode::NewMessageNotification(_, _, _) | UiMode::ViewingMessage(_) @@ -441,6 +442,7 @@ fn handle_down_key( | UiMode::UserMode(UserMode::WaitingAddInvoice) | UiMode::HelpPopup(..) | UiMode::SettingsInstructionsPopup(..) + | UiMode::OrderFilters(_) | UiMode::OperationResult(_) | UiMode::NewMessageNotification(_, _, _) | UiMode::ViewingMessage(_) From 7b335314413f265d82e12128a96f167b467355cc Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Tue, 18 Aug 2026 16:31:18 -0600 Subject: [PATCH 03/13] feat(ui): render orders filter UI --- src/ui/constants.rs | 2 + src/ui/help_popup.rs | 2 + src/ui/tabs/orders_tab.rs | 209 +++++++++++++++++++++++++++++++++++--- 3 files changed, 199 insertions(+), 14 deletions(-) diff --git a/src/ui/constants.rs b/src/ui/constants.rs index 46c46a49..025dff66 100644 --- a/src/ui/constants.rs +++ b/src/ui/constants.rs @@ -109,6 +109,8 @@ pub const HELP_EXIT_ENTER_CONFIRM: &str = "Enter: Confirm exit (then Yes/No)"; pub const HELP_ORDERS_ENTER_TAKE: &str = "Enter: Take selected order (or cancel if it is your pending listing)"; pub const HELP_ORDERS_SELECT: &str = "↑↓: Select order"; +pub const HELP_ORDERS_SHIFT_F_FILTERS: &str = "Shift+F: Edit order filters"; +pub const HELP_ORDERS_SHIFT_X_CLEAR_FILTERS: &str = "Shift+X: Clear order filters"; /// Confirmation body when Enter on Orders targets a maker pending order we own. pub const HELP_ORDERS_CANCEL_PENDING_MSG: &str = "Cancel this pending order? It will be removed from the order book."; diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 670663a9..792b32ae 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -408,6 +408,8 @@ fn help_content(app: &AppState, tab: Tab) -> (String, Vec) { vec![ HELP_ORDERS_ENTER_TAKE.to_string(), HELP_ORDERS_SELECT.to_string(), + HELP_ORDERS_SHIFT_F_FILTERS.to_string(), + HELP_ORDERS_SHIFT_X_CLEAR_FILTERS.to_string(), ], ), Tab::User(UserTab::MyTrades) => ( diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index 1e0dc192..16f4446a 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -1,15 +1,16 @@ use std::sync::{Arc, Mutex}; use mostro_core::prelude::*; -use ratatui::layout::{Constraint, Rect}; +use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; -use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}; use crate::ui::helpers::{ format_local_timestamp, format_premium, get_filtered_book_orders, render_table_list_scrollbar, selected_book_display_idx, }; +use crate::ui::orders::{OrderBookFilterField, OrderBookFilterState}; use crate::ui::{apply_kind_color, AppState, BACKGROUND_COLOR, PRIMARY_COLOR}; /// Renders the available orders table, with fewer columns when terminal width is limited. @@ -66,10 +67,11 @@ pub fn render_orders_tab( return; } - let filtered = get_filtered_book_orders(&orders_lock, &app.currencies_filter); + let filtered = + get_filtered_book_orders(&orders_lock, &app.currencies_filter, &app.order_filters); if filtered.is_empty() { let paragraph = Paragraph::new(Span::styled( - "📭 No offers match the current currency filter…", + "📭 No offers match the current filters…", Style::default().fg(Color::Yellow), )) .block( @@ -84,13 +86,19 @@ pub fn render_orders_tab( return; } + let (filter_area, table_area) = + split_filter_and_table(area, app.order_filters.has_active_filters()); + if let Some(filter_area) = filter_area { + render_order_filter_bar(f, filter_area, app); + } + let display_selected_idx = selected_book_display_idx(app.selected_order_id, &filtered).unwrap_or(0); - let compact = area.width < 100; + let compact = table_area.width < 100; // Drop the header when height < 4 so at least one data row stays visible // (same short-terminal rule as Disputes Pending). - let show_header = area.height >= 4; + let show_header = table_area.height >= 4; let header_labels = if compact { vec!["📈 Kind", "💵 Fiat Amt", "± Premium", "💳 Payment"] } else { @@ -225,13 +233,13 @@ pub fn render_orders_tab( } app.orders_table_state.select(Some(display_selected_idx)); - f.render_stateful_widget(table, area, &mut app.orders_table_state); + f.render_stateful_widget(table, table_area, &mut app.orders_table_state); let header_rows = u16::from(show_header); - let visible_rows = area.height.saturating_sub(2 + header_rows) as usize; + let visible_rows = table_area.height.saturating_sub(2 + header_rows) as usize; render_table_list_scrollbar( f, - area, + table_area, row_count, visible_rows, header_rows, @@ -239,11 +247,143 @@ pub fn render_orders_tab( ); } +fn split_filter_and_table(area: Rect, has_filters: bool) -> (Option, Rect) { + if area.height < 7 { + return (None, area); + } + let filter_height = if has_filters { 3 } else { 2 }; + let chunks = Layout::new( + Direction::Vertical, + [Constraint::Length(filter_height), Constraint::Min(3)], + ) + .split(area); + (Some(chunks[0]), chunks[1]) +} + +fn render_order_filter_bar(f: &mut ratatui::Frame, area: Rect, app: &AppState) { + let summary = app.order_filters.summary(); + let hint = if area.width < 80 { + "Shift+F filters | Shift+X clear" + } else { + "Shift+F: edit filters | Shift+X: clear filters | Enter: take/cancel selected order" + }; + let text = if app.order_filters.has_active_filters() { + vec![ + Line::from(vec![ + Span::styled("Filters: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(summary), + ]), + Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))), + ] + } else { + vec![Line::from(Span::styled( + hint, + Style::default().fg(Color::DarkGray), + ))] + }; + f.render_widget( + Paragraph::new(text).wrap(Wrap { trim: true }).block( + Block::default() + .title("Order Filters") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(PRIMARY_COLOR)) + .style(Style::default().bg(BACKGROUND_COLOR)), + ), + area, + ); +} + fn premium_cell(premium: i64) -> Cell<'static> { let (text, color) = format_premium(premium); Cell::from(text).style(Style::default().fg(color)) } +pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilterState) { + let area = f.area(); + let popup = center_rect( + area, + 72.min(area.width.saturating_sub(2)), + 15.min(area.height), + ); + f.render_widget(Clear, popup); + + let rows = OrderBookFilterField::ALL + .iter() + .map(|field| { + let selected = *field == state.focused; + let style = if selected { + Style::default().bg(PRIMARY_COLOR).fg(Color::Black) + } else { + Style::default().fg(Color::White) + }; + Line::from(vec![ + Span::styled(if selected { ">" } else { " " }, style), + Span::styled(format!(" {:<20}", field.label()), style), + Span::styled(filter_field_value(state, *field), style), + ]) + }) + .collect::>(); + + let mut lines = vec![ + Line::from(Span::styled( + "Enter Apply | Esc Cancel | Up/Down Field | Space Cycle kind | Ctrl+X Clear", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + ]; + lines.extend(rows); + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Left) + .block( + Block::default() + .title("Order Filters") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(PRIMARY_COLOR)) + .style(Style::default().bg(BACKGROUND_COLOR)), + ) + .wrap(Wrap { trim: false }), + popup, + ); +} + +fn filter_field_value(state: &OrderBookFilterState, field: OrderBookFilterField) -> String { + match field { + OrderBookFilterField::Kind => state.filters.kind.label().to_string(), + OrderBookFilterField::FiatCurrency => empty_display(&state.filters.fiat_code), + OrderBookFilterField::FiatAmountMin => empty_display(&state.filters.fiat_amount_min), + OrderBookFilterField::FiatAmountMax => empty_display(&state.filters.fiat_amount_max), + OrderBookFilterField::PremiumMin => empty_display(&state.filters.premium_min), + OrderBookFilterField::PremiumMax => empty_display(&state.filters.premium_max), + OrderBookFilterField::PaymentMethod => empty_display(&state.filters.payment_method), + OrderBookFilterField::CreatedWithinDays => { + empty_display(&state.filters.created_within_days) + } + } +} + +fn empty_display(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "Any".to_string() + } else { + trimmed.to_string() + } +} + +fn center_rect(area: Rect, width: u16, height: u16) -> Rect { + let [area] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([Constraint::Length(height)]) + .flex(Flex::Center) + .areas(area); + area +} + #[cfg(test)] mod tests { use super::*; @@ -306,6 +446,46 @@ mod tests { assert!(!buffer_contains(&buf, "Created")); } + #[test] + fn orders_table_shows_active_filter_summary() { + let backend = TestBackend::new(130, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let orders = Arc::new(Mutex::new(vec![sample_order("SEPA", 2)])); + let mut app = AppState::new(UserRole::User); + app.order_filters.fiat_code = "USD".to_string(); + app.order_filters.payment_method = "sep".to_string(); + + terminal + .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Filters:")); + assert!(buffer_contains(buf, "fiat=USD")); + assert!(buffer_contains(buf, "payment~sep")); + } + + #[test] + fn order_filter_popup_lists_all_filter_fields() { + let backend = TestBackend::new(90, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = OrderBookFilterState::default(); + state.filters.payment_method = "cash".to_string(); + + terminal + .draw(|f| render_order_filter_popup(f, &state)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Buy/Sell")); + assert!(buffer_contains(buf, "Fiat currency")); + assert!(buffer_contains(buf, "Fiat amount min")); + assert!(buffer_contains(buf, "Premium max %")); + assert!(buffer_contains(buf, "Payment method")); + assert!(buffer_contains(buf, "Created within days")); + assert!(buffer_contains(buf, "cash")); + } + /// When more orders exist than table body rows, selecting a late row must /// scroll the stateful table so that marker is visible. #[test] @@ -320,8 +500,9 @@ mod tests { let orders = Arc::new(Mutex::new(book)); let mut app = AppState::new(UserRole::User); app.selected_order_id = Some(last_id); - // Height 10 → ~7 body rows after borders+header; selecting last must scroll. - let backend = TestBackend::new(130, 10); + // Height 6 keeps the Orders table compact enough to skip the filter bar while still + // leaving body rows to scroll. + let backend = TestBackend::new(130, 6); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) @@ -352,7 +533,7 @@ mod tests { let orders = Arc::new(Mutex::new(book)); let mut app = AppState::new(UserRole::User); app.selected_order_id = Some(first_id); - let backend = TestBackend::new(130, 10); + let backend = TestBackend::new(130, 6); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) @@ -435,7 +616,7 @@ mod tests { let mut app = AppState::new(UserRole::User); app.selected_order_id = Some(last_id); - let backend = TestBackend::new(130, 10); + let backend = TestBackend::new(130, 6); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) From 3d12d69be7f6ba4f2e1fd298d61686f89843f647 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Wed, 19 Aug 2026 12:31:52 -0600 Subject: [PATCH 04/13] feat(ui): expose order filter shortcuts --- src/main.rs | 9 +++++-- src/ui/tabs/orders_tab.rs | 34 ++++++++++++------------ src/util/order_utils/helper.rs | 48 +++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index e7d4d2f8..c794500d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -960,6 +960,11 @@ async fn main() -> Result<(), anyhow::Error> { true => "All currencies are accepted".to_string(), false => current_settings.currencies_filter.join(", "), }; + let order_filter_shortcuts = if app.user_role == UserRole::User { + " | Shift+F: Order filters | Shift+X: Clear order filters" + } else { + "" + }; // Mostro name (Lightning node alias) from instance info let mostro_alias = match app.mostro_info.as_ref() { Some(info) => info @@ -976,8 +981,8 @@ async fn main() -> Result<(), anyhow::Error> { ), format!("🔗 Relays: {}", relays_str), format!( - "💱 Currencies: {} - Filters: {}", - mostro_instance_currencies, currencies_filter_str + "💱 Currencies: {} - Filters: {}{}", + mostro_instance_currencies, currencies_filter_str, order_filter_shortcuts ), ]; terminal.draw(|f| ui_draw(f, &mut app, &orders, &disputes, Some(&status_lines)))?; diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index 16f4446a..beff6b44 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -248,13 +248,12 @@ pub fn render_orders_tab( } fn split_filter_and_table(area: Rect, has_filters: bool) -> (Option, Rect) { - if area.height < 7 { + if !has_filters || area.height < 7 { return (None, area); } - let filter_height = if has_filters { 3 } else { 2 }; let chunks = Layout::new( Direction::Vertical, - [Constraint::Length(filter_height), Constraint::Min(3)], + [Constraint::Length(3), Constraint::Min(3)], ) .split(area); (Some(chunks[0]), chunks[1]) @@ -267,20 +266,13 @@ fn render_order_filter_bar(f: &mut ratatui::Frame, area: Rect, app: &AppState) { } else { "Shift+F: edit filters | Shift+X: clear filters | Enter: take/cancel selected order" }; - let text = if app.order_filters.has_active_filters() { - vec![ - Line::from(vec![ - Span::styled("Filters: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(summary), - ]), - Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))), - ] - } else { - vec![Line::from(Span::styled( - hint, - Style::default().fg(Color::DarkGray), - ))] - }; + let text = vec![ + Line::from(vec![ + Span::styled("Filters: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(summary), + ]), + Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))), + ]; f.render_widget( Paragraph::new(text).wrap(Wrap { trim: true }).block( Block::default() @@ -327,7 +319,11 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter let mut lines = vec![ Line::from(Span::styled( - "Enter Apply | Esc Cancel | Up/Down Field | Space Cycle kind | Ctrl+X Clear", + "Enter Apply | Esc Cancel | Up/Down Field", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + "Space Cycle kind | Shift+X Clear", Style::default().fg(Color::DarkGray), )), Line::from(""), @@ -434,6 +430,7 @@ mod tests { assert!(buffer_contains(&buf, "Premium")); assert!(buffer_contains(&buf, "-3%")); assert!(buffer_contains(&buf, "SEPA")); + assert!(!buffer_contains(&buf, "Order Filters")); } #[test] @@ -483,6 +480,7 @@ mod tests { assert!(buffer_contains(buf, "Premium max %")); assert!(buffer_contains(buf, "Payment method")); assert!(buffer_contains(buf, "Created within days")); + assert!(buffer_contains(buf, "Shift+X Clear")); assert!(buffer_contains(buf, "cash")); } diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 4901388f..adda1dac 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -419,10 +419,7 @@ pub fn parse_orders_events( .filter(|o| { // If currencies filter is provided and not empty, filter by any currency in the list // If currencies is None or empty, show all orders (no filter) - currencies - .as_ref() - .map(|currencies| currencies.is_empty() || currencies.contains(&o.fiat_code)) - .unwrap_or(true) + order_matches_currency_filter(o, currencies.as_deref()) }) .filter(|o| { kind.as_ref() @@ -469,10 +466,7 @@ pub fn pending_orders_for_book( .values() .filter(|o| { o.status == Some(Status::Pending) - && currencies - .as_ref() - .map(|currencies| currencies.is_empty() || currencies.contains(&o.fiat_code)) - .unwrap_or(true) + && order_matches_currency_filter(o, currencies.as_deref()) }) .cloned() .collect(); @@ -480,6 +474,17 @@ pub fn pending_orders_for_book( requested } +fn order_matches_currency_filter(order: &SmallOrder, currencies: Option<&[String]>) -> bool { + currencies + .map(|currencies| { + currencies.is_empty() + || currencies + .iter() + .any(|currency| currency.eq_ignore_ascii_case(&order.fiat_code)) + }) + .unwrap_or(true) +} + /// Fetch events list using the same logic as mostro-cli (adapted for mostrix) pub async fn fetch_events_list( list_kind: ListKind, @@ -816,10 +821,13 @@ mod tests { use super::{ admin_finalize_ack, dispute_from_tags, handle_mostro_response, inferred_status_from_trade_action, is_terminal_trade_status, parse_disputes_events, - should_apply_status_transition, should_strictly_advance_status, AdminFinalizeAck, + pending_orders_for_book, should_apply_status_transition, should_strictly_advance_status, + AdminFinalizeAck, }; use crate::models::TERMINAL_ORDER_HISTORY_STATUSES; - use mostro_core::prelude::{Action, DisputeStatus, Message, Status, NOSTR_DISPUTE_EVENT_KIND}; + use mostro_core::prelude::{ + Action, DisputeStatus, Message, SmallOrder, Status, NOSTR_DISPUTE_EVENT_KIND, + }; use nostr_sdk::prelude::*; use std::collections::BTreeSet; use std::str::FromStr; @@ -912,6 +920,26 @@ mod tests { assert_eq!(parsed[0].created_at, 1_800_000_000); } + #[test] + fn pending_orders_match_currency_filter_case_insensitively() { + let id = Uuid::new_v4(); + let mut latest = std::collections::HashMap::new(); + latest.insert( + id, + SmallOrder { + id: Some(id), + status: Some(Status::Pending), + fiat_code: "usd".to_string(), + ..Default::default() + }, + ); + + let filtered = pending_orders_for_book(&latest, Some(vec!["USD".to_string()])); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, Some(id)); + } + #[test] fn parse_disputes_latest_revision_wins_by_event_stamp_not_open_tag() { let keys = Keys::generate(); From 7b2561fbb6b6fdf35a3c2c8e77b401794dde9b4d Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Wed, 19 Aug 2026 13:05:19 -0600 Subject: [PATCH 05/13] fix(ui): address order filter review feedback --- src/ui/help_popup.rs | 61 ++++++++++++++++- src/ui/key_handler/mod.rs | 30 ++++++++- src/ui/tabs/orders_tab.rs | 133 ++++++++++++++++++++++++++++++++------ 3 files changed, 200 insertions(+), 24 deletions(-) diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 792b32ae..649dc603 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -10,6 +10,8 @@ use crate::ui::navigation::{AdminTab, Tab, UserRole, UserTab}; // 13 shortcuts, intro, close hint, borders, and one row of margin above and below. const MY_TRADES_FULL_HELP_MIN_HEIGHT: u16 = 19; const MY_TRADES_FULL_HELP_MIN_WIDTH: u16 = 60; +const ORDERS_FULL_HELP_MIN_HEIGHT: u16 = 11; +const ORDERS_FULL_HELP_MIN_WIDTH: u16 = 48; /// Renders the context-aware keyboard shortcuts popup (Ctrl+H, and Shift+H on My Trades). pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { @@ -19,11 +21,17 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { matches!(tab, Tab::User(UserTab::MyTrades)) && area.width < MY_TRADES_FULL_HELP_MIN_WIDTH; let compact_my_trades = matches!(tab, Tab::User(UserTab::MyTrades)) && (area.height < MY_TRADES_FULL_HELP_MIN_HEIGHT || narrow_my_trades); + let narrow_orders = + matches!(tab, Tab::User(UserTab::Orders)) && area.width < ORDERS_FULL_HELP_MIN_WIDTH; + let compact_orders = matches!(tab, Tab::User(UserTab::Orders)) + && (area.height < ORDERS_FULL_HELP_MIN_HEIGHT || narrow_orders); // Match Settings Shift+H: compact rows, styled shortcut + description, full viewport height. let compact_chrome = matches!( tab, - Tab::Admin(AdminTab::DisputesInProgress) | Tab::User(UserTab::MyTrades) + Tab::Admin(AdminTab::DisputesInProgress) + | Tab::User(UserTab::Orders) + | Tab::User(UserTab::MyTrades) ); let (popup_width, popup_height) = if compact_chrome { @@ -64,12 +72,18 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { let mut lines: Vec> = Vec::new(); if matches!(tab, Tab::Admin(AdminTab::DisputesInProgress)) { lines.push(help_disputes_in_progress_intro()); + } else if compact_orders { + lines.extend(compact_orders_help(narrow_orders)); } else if compact_my_trades { lines.extend(compact_my_trades_help(narrow_my_trades)); + } else if matches!(tab, Tab::User(UserTab::Orders)) { + for s in &plain_lines { + lines.push(help_shortcut_line(s)); + } } else { lines.push(help_my_trades_intro()); } - if !compact_my_trades { + if !compact_my_trades && !matches!(tab, Tab::User(UserTab::Orders)) { for s in plain_lines { lines.push(help_shortcut_line(&s)); } @@ -216,6 +230,24 @@ fn compact_my_trades_help(narrow: bool) -> Vec> { .collect() } +fn compact_orders_help(narrow: bool) -> Vec> { + if narrow { + let (title_style, _) = settings_instruction_block_style(); + return ["↑↓ Enter", "Shift+F Shift+X"] + .into_iter() + .map(|row| Line::from(Span::styled(row, title_style))) + .collect(); + } + + [ + "↑↓ / Enter: Select / take order", + "Shift+F / Shift+X: Edit / clear filters", + ] + .into_iter() + .map(help_shortcut_line) + .collect() +} + /// Split `Key: description` help strings into bold key + gray body (same as Settings Shift+H rows). fn help_shortcut_line(s: &str) -> Line<'static> { let (title_style, body_style) = settings_instruction_block_style(); @@ -564,4 +596,29 @@ mod help_content_tests { ); } } + + #[test] + fn short_narrow_orders_help_keeps_filter_shortcuts_and_close_hint_visible() { + let backend = TestBackend::new(32, 8); + let mut terminal = Terminal::new(backend).unwrap(); + let app = AppState::new(UserRole::User); + + terminal + .draw(|f| render_help_popup(f, &app, Tab::User(UserTab::Orders))) + .unwrap(); + + let buf = terminal.backend().buffer(); + for expected in [ + "Enter", + "Shift+F", + "Shift+X", + "Esc, Enter or", + "Ctrl+H to close", + ] { + assert!( + buffer_contains(buf, expected), + "missing {expected:?} from compact Orders help" + ); + } + } } diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 285fe863..84f00326 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -697,7 +697,7 @@ fn handle_order_filter_popup_key(app: &mut AppState, code: KeyCode, key_event: & app.mode = UiMode::OrderFilters(state); } KeyCode::Char('x') | KeyCode::Char('X') - if key_event.modifiers.contains(KeyModifiers::CONTROL) => + if key_event.modifiers.contains(KeyModifiers::SHIFT) => { state.filters = OrderBookFilters::default(); app.mode = UiMode::OrderFilters(state); @@ -1941,6 +1941,34 @@ mod key_handler_tests { } } + #[test] + fn shift_x_clears_order_filter_popup_state() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters { + fiat_code: "MXN".to_string(), + payment_method: "cash".to_string(), + ..Default::default() + }, + focused: OrderBookFilterField::FiatCurrency, + }); + + let handled = handle_order_filter_popup_key( + &mut app, + KeyCode::Char('X'), + &KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT), + ); + + assert!(handled); + match app.mode { + UiMode::OrderFilters(state) => { + assert!(state.filters.fiat_code.is_empty()); + assert!(state.filters.payment_method.is_empty()); + } + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + #[test] fn trade_action_shortcuts_open_the_right_confirmation_when_interactive() { let order_id = uuid::Uuid::new_v4(); diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index beff6b44..befb7686 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -69,6 +69,12 @@ pub fn render_orders_tab( let filtered = get_filtered_book_orders(&orders_lock, &app.currencies_filter, &app.order_filters); + let (filter_area, table_area) = + split_filter_and_table(area, app.order_filters.has_active_filters()); + if let Some(filter_area) = filter_area { + render_order_filter_bar(f, filter_area, app); + } + if filtered.is_empty() { let paragraph = Paragraph::new(Span::styled( "📭 No offers match the current filters…", @@ -82,16 +88,10 @@ pub fn render_orders_tab( .border_style(Style::default().fg(PRIMARY_COLOR)) .style(Style::default().bg(BACKGROUND_COLOR)), ); - f.render_widget(paragraph, area); + f.render_widget(paragraph, table_area); return; } - let (filter_area, table_area) = - split_filter_and_table(area, app.order_filters.has_active_filters()); - if let Some(filter_area) = filter_area { - render_order_filter_bar(f, filter_area, app); - } - let display_selected_idx = selected_book_display_idx(app.selected_order_id, &filtered).unwrap_or(0); @@ -300,6 +300,30 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter ); f.render_widget(Clear, popup); + let compact = popup.height < 13 || popup.width < 52; + let lines = if compact { + compact_order_filter_popup_lines(state) + } else { + full_order_filter_popup_lines(state) + }; + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Left) + .block( + Block::default() + .title("Order Filters") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(PRIMARY_COLOR)) + .style(Style::default().bg(BACKGROUND_COLOR)), + ) + .wrap(Wrap { trim: false }), + popup, + ); +} + +fn full_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec> { let rows = OrderBookFilterField::ALL .iter() .map(|field| { @@ -329,21 +353,30 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter Line::from(""), ]; lines.extend(rows); + lines +} - f.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Left) - .block( - Block::default() - .title("Order Filters") - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(PRIMARY_COLOR)) - .style(Style::default().bg(BACKGROUND_COLOR)), - ) - .wrap(Wrap { trim: false }), - popup, - ); +fn compact_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec> { + let label = state.focused.label(); + let value = filter_field_value(state, state.focused); + vec![ + Line::from(Span::styled( + "Enter Apply | Esc Cancel", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + "Up/Down Field | Shift+X Clear", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + label, + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled(value, Style::default().fg(Color::White))), + ] } fn filter_field_value(state: &OrderBookFilterState, field: OrderBookFilterField) -> String { @@ -462,6 +495,24 @@ mod tests { assert!(buffer_contains(buf, "payment~sep")); } + #[test] + fn orders_table_keeps_filter_summary_when_filters_match_no_orders() { + let backend = TestBackend::new(130, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let orders = Arc::new(Mutex::new(vec![sample_order("SEPA", 2)])); + let mut app = AppState::new(UserRole::User); + app.order_filters.fiat_code = "EUR".to_string(); + + terminal + .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Filters:")); + assert!(buffer_contains(buf, "fiat=EUR")); + assert!(buffer_contains(buf, "No offers match")); + } + #[test] fn order_filter_popup_lists_all_filter_fields() { let backend = TestBackend::new(90, 24); @@ -484,6 +535,46 @@ mod tests { assert!(buffer_contains(buf, "cash")); } + #[test] + fn short_order_filter_popup_keeps_focused_field_visible() { + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = OrderBookFilterState { + focused: OrderBookFilterField::PremiumMax, + ..Default::default() + }; + state.filters.premium_max = "12".to_string(); + + terminal + .draw(|f| render_order_filter_popup(f, &state)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Premium max %")); + assert!(buffer_contains(buf, "12")); + assert!(buffer_contains(buf, "Shift+X Clear")); + } + + #[test] + fn narrow_order_filter_popup_keeps_focused_field_visible() { + let backend = TestBackend::new(40, 18); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = OrderBookFilterState { + focused: OrderBookFilterField::PaymentMethod, + ..Default::default() + }; + state.filters.payment_method = "cash".to_string(); + + terminal + .draw(|f| render_order_filter_popup(f, &state)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Payment method")); + assert!(buffer_contains(buf, "cash")); + assert!(buffer_contains(buf, "Shift+X Clear")); + } + /// When more orders exist than table body rows, selecting a late row must /// scroll the stateful table so that marker is visible. #[test] From fdca734f2f9ad7c3342c826190c72a321da567e8 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Wed, 19 Aug 2026 23:10:33 -0600 Subject: [PATCH 06/13] style(ui): improve order filter popup usability --- src/ui/key_handler/mod.rs | 112 +++++++++++++++------ src/ui/tabs/orders_tab.rs | 204 ++++++++++++++++++++++++++++++-------- 2 files changed, 246 insertions(+), 70 deletions(-) diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 84f00326..eab25363 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -635,6 +635,14 @@ fn is_paste_shortcut(key_event: &KeyEvent) -> bool { } } +fn is_shift_char_shortcut(key_event: &KeyEvent, lower: char, upper: char) -> bool { + match key_event.code { + KeyCode::Char(c) if c == upper => true, + KeyCode::Char(c) if c == lower => key_event.modifiers.contains(KeyModifiers::SHIFT), + _ => false, + } +} + fn update_invoice_notification_action_selection( code: KeyCode, invoice_state: &mut crate::ui::InvoiceInputState, @@ -696,9 +704,7 @@ fn handle_order_filter_popup_key(app: &mut AppState, code: KeyCode, key_event: & state.filters.kind.cycle(); app.mode = UiMode::OrderFilters(state); } - KeyCode::Char('x') | KeyCode::Char('X') - if key_event.modifiers.contains(KeyModifiers::SHIFT) => - { + KeyCode::Char('x') | KeyCode::Char('X') if is_shift_char_shortcut(key_event, 'x', 'X') => { state.filters = OrderBookFilters::default(); app.mode = UiMode::OrderFilters(state); } @@ -1523,36 +1529,35 @@ pub fn handle_key_event( } if let Tab::User(UserTab::Orders) = app.active_tab { - let has_shift = key_event - .modifiers - .contains(crossterm::event::KeyModifiers::SHIFT); - if has_shift { - match code { - KeyCode::Char('f') | KeyCode::Char('F') => { - if matches!( - app.mode, - UiMode::Normal | UiMode::UserMode(UserMode::Normal) - ) { - app.mode = UiMode::OrderFilters(OrderBookFilterState { - filters: app.order_filters.clone(), - focused: OrderBookFilterField::Kind, - }); - return Some(true); - } + match code { + KeyCode::Char('f') | KeyCode::Char('F') + if is_shift_char_shortcut(&key_event, 'f', 'F') => + { + if matches!( + app.mode, + UiMode::Normal | UiMode::UserMode(UserMode::Normal) + ) { + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: app.order_filters.clone(), + focused: OrderBookFilterField::Kind, + }); + return Some(true); } - KeyCode::Char('x') | KeyCode::Char('X') => { - if matches!( - app.mode, - UiMode::Normal | UiMode::UserMode(UserMode::Normal) - ) { - app.order_filters = OrderBookFilters::default(); - app.selected_order_id = None; - app.orders_table_state = ratatui::widgets::TableState::default(); - return Some(true); - } + } + KeyCode::Char('x') | KeyCode::Char('X') + if is_shift_char_shortcut(&key_event, 'x', 'X') => + { + if matches!( + app.mode, + UiMode::Normal | UiMode::UserMode(UserMode::Normal) + ) { + app.order_filters = OrderBookFilters::default(); + app.selected_order_id = None; + app.orders_table_state = ratatui::widgets::TableState::default(); + return Some(true); } - _ => {} } + _ => {} } } @@ -1969,6 +1974,53 @@ mod key_handler_tests { } } + #[test] + fn uppercase_x_without_shift_modifier_clears_order_filter_popup_state() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters { + fiat_code: "MXN".to_string(), + payment_method: "cash".to_string(), + ..Default::default() + }, + focused: OrderBookFilterField::FiatCurrency, + }); + + let handled = handle_order_filter_popup_key( + &mut app, + KeyCode::Char('X'), + &KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), + ); + + assert!(handled); + match app.mode { + UiMode::OrderFilters(state) => { + assert!(state.filters.fiat_code.is_empty()); + assert!(state.filters.payment_method.is_empty()); + } + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + + #[test] + fn order_filter_shortcut_detection_accepts_shift_or_uppercase() { + assert!(is_shift_char_shortcut( + &KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT), + 'f', + 'F' + )); + assert!(is_shift_char_shortcut( + &KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE), + 'f', + 'F' + )); + assert!(!is_shift_char_shortcut( + &KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), + 'f', + 'F' + )); + } + #[test] fn trade_action_shortcuts_open_the_right_confirmation_when_interactive() { let order_id = uuid::Uuid::new_v4(); diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index befb7686..29659d66 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -268,8 +268,13 @@ fn render_order_filter_bar(f: &mut ratatui::Frame, area: Rect, app: &AppState) { }; let text = vec![ Line::from(vec![ - Span::styled("Filters: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(summary), + Span::styled( + "Filters: ", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(summary, Style::default().fg(Color::White)), ]), Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))), ]; @@ -295,12 +300,12 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter let area = f.area(); let popup = center_rect( area, - 72.min(area.width.saturating_sub(2)), - 15.min(area.height), + 76.min(area.width.saturating_sub(2)), + 16.min(area.height), ); f.render_widget(Clear, popup); - let compact = popup.height < 13 || popup.width < 52; + let compact = popup.height < 14 || popup.width < 56; let lines = if compact { compact_order_filter_popup_lines(state) } else { @@ -324,35 +329,19 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter } fn full_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec> { - let rows = OrderBookFilterField::ALL - .iter() - .map(|field| { - let selected = *field == state.focused; - let style = if selected { - Style::default().bg(PRIMARY_COLOR).fg(Color::Black) - } else { - Style::default().fg(Color::White) - }; - Line::from(vec![ - Span::styled(if selected { ">" } else { " " }, style), - Span::styled(format!(" {:<20}", field.label()), style), - Span::styled(filter_field_value(state, *field), style), - ]) - }) - .collect::>(); - let mut lines = vec![ - Line::from(Span::styled( - "Enter Apply | Esc Cancel | Up/Down Field", - Style::default().fg(Color::DarkGray), - )), - Line::from(Span::styled( - "Space Cycle kind | Shift+X Clear", - Style::default().fg(Color::DarkGray), - )), + shortcut_line(&[("Enter", "Apply"), ("Esc", "Cancel"), ("Up/Down", "Field")]), + shortcut_line(&[("Space", "Cycle kind"), ("Shift+X", "Clear")]), + active_filters_line(state), Line::from(""), ]; - lines.extend(rows); + lines.extend( + OrderBookFilterField::ALL + .iter() + .map(|field| filter_field_line(state, *field)), + ); + lines.push(Line::from("")); + lines.push(focused_field_hint_line(state.focused)); lines } @@ -360,14 +349,8 @@ fn compact_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec Vec Line<'static> { + if state.filters.has_active_filters() { + Line::from(vec![ + Span::styled("Active: ", Style::default().fg(Color::DarkGray)), + Span::styled( + state.filters.summary(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(Span::styled( + "Active: no filters. Set fields below, then press Enter.", + Style::default().fg(Color::DarkGray), + )) + } +} + +fn shortcut_line(items: &[(&'static str, &'static str)]) -> Line<'static> { + let mut spans = Vec::new(); + for (idx, (key, label)) in items.iter().enumerate() { + if idx > 0 { + spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); + } + spans.push(Span::styled( + *key, + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!(" {label}"), + Style::default().fg(Color::DarkGray), + )); + } + Line::from(spans) +} + +fn filter_field_line(state: &OrderBookFilterState, field: OrderBookFilterField) -> Line<'static> { + Line::from(filter_field_spans(state, field, 20, 0)) +} + +fn filter_field_spans( + state: &OrderBookFilterState, + field: OrderBookFilterField, + label_width: usize, + value_width: usize, +) -> Vec> { + let selected = field == state.focused; + let row_style = if selected { + Style::default() + .fg(Color::Black) + .bg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let label_style = if selected { + row_style + } else { + Style::default().fg(Color::Gray) + }; + let value = filter_field_value(state, field); + let value_style = if value == "Any" { + if selected { + row_style + } else { + Style::default().fg(Color::DarkGray) + } + } else { + if selected { + row_style + } else { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } + }; + let value = if value_width == 0 { + if selected && active_order_filter_field_accepts_text(field) && value == "Any" { + "Type value...".to_string() + } else { + value + } + } else { + truncate_for_cell(&value, value_width) + }; + + vec![ + Span::styled(if selected { "> " } else { " " }, row_style), + Span::styled(format!("{: bool { + !matches!(field, OrderBookFilterField::Kind) +} + +fn focused_field_hint_line(field: OrderBookFilterField) -> Line<'static> { + let hint = match field { + OrderBookFilterField::Kind => "Space cycles Buy/Sell. Use Any to include both.", + OrderBookFilterField::FiatCurrency => "Type a currency code, for example MXN or USD.", + OrderBookFilterField::FiatAmountMin => "Type the minimum fiat amount to include.", + OrderBookFilterField::FiatAmountMax => "Type the maximum fiat amount to include.", + OrderBookFilterField::PremiumMin => { + "Type the minimum premium percent, negative values allowed." + } + OrderBookFilterField::PremiumMax => { + "Type the maximum premium percent, negative values allowed." + } + OrderBookFilterField::PaymentMethod => "Type part of a payment method, for example SPEI.", + OrderBookFilterField::CreatedWithinDays => "Type a number of days, for example 7.", + }; + Line::from(vec![ + Span::styled("Hint: ", Style::default().fg(PRIMARY_COLOR)), + Span::styled(hint, Style::default().fg(Color::DarkGray)), + ]) +} + +fn truncate_for_cell(value: &str, width: usize) -> String { + let mut chars = value.chars(); + let mut out: String = chars.by_ref().take(width).collect(); + if chars.next().is_some() && width > 1 { + out.pop(); + out.push('.'); + } + format!("{out: String { match field { OrderBookFilterField::Kind => state.filters.kind.label().to_string(), From 1dc1af899ed5aead21718d3a7010a61a0d2086ac Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Wed, 19 Aug 2026 23:52:30 -0600 Subject: [PATCH 07/13] fix(ui): address order filter review feedback --- src/main.rs | 6 +- src/ui/tabs/orders_tab.rs | 183 ++++++++++++++++++++------- src/util/order_utils/helper.rs | 217 +++++++++++++++++++++++++++++---- 3 files changed, 336 insertions(+), 70 deletions(-) diff --git a/src/main.rs b/src/main.rs index c794500d..5753c151 100644 --- a/src/main.rs +++ b/src/main.rs @@ -960,7 +960,11 @@ async fn main() -> Result<(), anyhow::Error> { true => "All currencies are accepted".to_string(), false => current_settings.currencies_filter.join(", "), }; - let order_filter_shortcuts = if app.user_role == UserRole::User { + let order_filter_shortcuts = if app.user_role == UserRole::User + && matches!( + app.active_tab, + crate::ui::navigation::Tab::User(crate::ui::navigation::UserTab::Orders) + ) { " | Shift+F: Order filters | Shift+X: Clear order filters" } else { "" diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index 29659d66..a4dcde88 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -253,7 +253,7 @@ fn split_filter_and_table(area: Rect, has_filters: bool) -> (Option, Rect) } let chunks = Layout::new( Direction::Vertical, - [Constraint::Length(3), Constraint::Min(3)], + [Constraint::Length(4), Constraint::Min(3)], ) .split(area); (Some(chunks[0]), chunks[1]) @@ -305,78 +305,97 @@ pub fn render_order_filter_popup(f: &mut ratatui::Frame, state: &OrderBookFilter ); f.render_widget(Clear, popup); + let inner_width = popup.width.saturating_sub(2) as usize; + let inner_height = popup.height.saturating_sub(2); let compact = popup.height < 14 || popup.width < 56; let lines = if compact { - compact_order_filter_popup_lines(state) + compact_order_filter_popup_lines(state, inner_width, inner_height) } else { - full_order_filter_popup_lines(state) + full_order_filter_popup_lines(state, inner_width) }; f.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Left) - .block( - Block::default() - .title("Order Filters") - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(PRIMARY_COLOR)) - .style(Style::default().bg(BACKGROUND_COLOR)), - ) - .wrap(Wrap { trim: false }), + Paragraph::new(lines).alignment(Alignment::Left).block( + Block::default() + .title("Order Filters") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(PRIMARY_COLOR)) + .style(Style::default().bg(BACKGROUND_COLOR)), + ), popup, ); } -fn full_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec> { +fn full_order_filter_popup_lines( + state: &OrderBookFilterState, + inner_width: usize, +) -> Vec> { + let label_width = 20; + let value_width = inner_width.saturating_sub(label_width + 3); let mut lines = vec![ shortcut_line(&[("Enter", "Apply"), ("Esc", "Cancel"), ("Up/Down", "Field")]), shortcut_line(&[("Space", "Cycle kind"), ("Shift+X", "Clear")]), - active_filters_line(state), + active_filters_line(state, inner_width), Line::from(""), ]; lines.extend( OrderBookFilterField::ALL .iter() - .map(|field| filter_field_line(state, *field)), + .map(|field| filter_field_line(state, *field, label_width, value_width)), ); lines.push(Line::from("")); - lines.push(focused_field_hint_line(state.focused)); + lines.push(focused_field_hint_line(state.focused, inner_width)); lines } -fn compact_order_filter_popup_lines(state: &OrderBookFilterState) -> Vec> { +fn compact_order_filter_popup_lines( + state: &OrderBookFilterState, + inner_width: usize, + inner_height: u16, +) -> Vec> { let label = state.focused.label(); - let value = filter_field_value(state, state.focused); - vec![ - shortcut_line(&[("Enter", "Apply"), ("Esc", "Cancel")]), - shortcut_line(&[("Up/Down", "Field"), ("Shift+X", "Clear")]), - Line::from(""), - Line::from(Span::styled( - label, + let value = compact_field_value(state, inner_width); + let mut lines = vec![shortcut_line(&[("Enter", "Apply"), ("Esc", "Cancel")])]; + + if inner_height > 4 { + lines.push(shortcut_line(&[("Up/Down", "Field"), ("Shift+X", "Clear")])); + } else { + lines.push(shortcut_line(&[("Shift+X", "Clear")])); + } + + lines.push(Line::from(Span::styled( + label, + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(vec![ + Span::styled("Value: ", Style::default().fg(Color::DarkGray)), + Span::styled( + value, Style::default() - .fg(PRIMARY_COLOR) + .fg(Color::White) .add_modifier(Modifier::BOLD), - )), - Line::from(vec![ - Span::styled("Value: ", Style::default().fg(Color::DarkGray)), - Span::styled( - value, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ]), - focused_field_hint_line(state.focused), - ] + ), + ])); + + if inner_height > 5 { + lines.push(focused_field_hint_line(state.focused, inner_width)); + } + + lines } -fn active_filters_line(state: &OrderBookFilterState) -> Line<'static> { +fn active_filters_line(state: &OrderBookFilterState, inner_width: usize) -> Line<'static> { if state.filters.has_active_filters() { + let summary_width = inner_width.saturating_sub("Active: ".len()); Line::from(vec![ Span::styled("Active: ", Style::default().fg(Color::DarkGray)), Span::styled( - state.filters.summary(), + truncate_for_cell(&state.filters.summary(), summary_width) + .trim_end() + .to_string(), Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), @@ -384,7 +403,12 @@ fn active_filters_line(state: &OrderBookFilterState) -> Line<'static> { ]) } else { Line::from(Span::styled( - "Active: no filters. Set fields below, then press Enter.", + truncate_for_cell( + "Active: no filters. Set fields below, then press Enter.", + inner_width, + ) + .trim_end() + .to_string(), Style::default().fg(Color::DarkGray), )) } @@ -410,8 +434,13 @@ fn shortcut_line(items: &[(&'static str, &'static str)]) -> Line<'static> { Line::from(spans) } -fn filter_field_line(state: &OrderBookFilterState, field: OrderBookFilterField) -> Line<'static> { - Line::from(filter_field_spans(state, field, 20, 0)) +fn filter_field_line( + state: &OrderBookFilterState, + field: OrderBookFilterField, + label_width: usize, + value_width: usize, +) -> Line<'static> { + Line::from(filter_field_spans(state, field, label_width, value_width)) } fn filter_field_spans( @@ -472,7 +501,7 @@ fn active_order_filter_field_accepts_text(field: OrderBookFilterField) -> bool { !matches!(field, OrderBookFilterField::Kind) } -fn focused_field_hint_line(field: OrderBookFilterField) -> Line<'static> { +fn focused_field_hint_line(field: OrderBookFilterField, inner_width: usize) -> Line<'static> { let hint = match field { OrderBookFilterField::Kind => "Space cycles Buy/Sell. Use Any to include both.", OrderBookFilterField::FiatCurrency => "Type a currency code, for example MXN or USD.", @@ -489,11 +518,19 @@ fn focused_field_hint_line(field: OrderBookFilterField) -> Line<'static> { }; Line::from(vec![ Span::styled("Hint: ", Style::default().fg(PRIMARY_COLOR)), - Span::styled(hint, Style::default().fg(Color::DarkGray)), + Span::styled( + truncate_for_cell(hint, inner_width.saturating_sub("Hint: ".len())) + .trim_end() + .to_string(), + Style::default().fg(Color::DarkGray), + ), ]) } fn truncate_for_cell(value: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } let mut chars = value.chars(); let mut out: String = chars.by_ref().take(width).collect(); if chars.next().is_some() && width > 1 { @@ -503,6 +540,18 @@ fn truncate_for_cell(value: &str, width: usize) -> String { format!("{out: String { + let raw_value = filter_field_value(state, state.focused); + let value = if state.focused != OrderBookFilterField::Kind && raw_value == "Any" { + "Type value...".to_string() + } else { + raw_value + }; + truncate_for_cell(&value, inner_width.saturating_sub("Value: ".len())) + .trim_end() + .to_string() +} + fn filter_field_value(state: &OrderBookFilterState, field: OrderBookFilterField) -> String { match field { OrderBookFilterField::Kind => state.filters.kind.label().to_string(), @@ -617,6 +666,7 @@ mod tests { assert!(buffer_contains(buf, "Filters:")); assert!(buffer_contains(buf, "fiat=USD")); assert!(buffer_contains(buf, "payment~sep")); + assert!(buffer_contains(buf, "Shift+F: edit filters")); } #[test] @@ -699,6 +749,47 @@ mod tests { assert!(buffer_contains(buf, "Shift+X Clear")); } + #[test] + fn tiny_order_filter_popup_keeps_clear_shortcut_and_value_visible() { + let backend = TestBackend::new(36, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = OrderBookFilterState { + focused: OrderBookFilterField::PaymentMethod, + ..Default::default() + }; + state.filters.payment_method = "cash".to_string(); + + terminal + .draw(|f| render_order_filter_popup(f, &state)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Shift+X Clear")); + assert!(buffer_contains(buf, "Payment method")); + assert!(buffer_contains(buf, "cash")); + } + + #[test] + fn order_filter_popup_truncates_long_values() { + let backend = TestBackend::new(78, 16); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = OrderBookFilterState { + focused: OrderBookFilterField::PaymentMethod, + ..Default::default() + }; + state.filters.payment_method = + "very long payment method name that should not wrap inside the popup".to_string(); + state.filters.fiat_code = "USD".to_string(); + + terminal + .draw(|f| render_order_filter_popup(f, &state)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "payment~very long payment method")); + assert!(!buffer_contains(buf, "should not wrap inside the popup")); + } + /// When more orders exist than table body rows, selecting a late row must /// scroll the stateful table so that marker is visible. #[test] diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index adda1dac..325f18bb 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -19,6 +19,18 @@ use tokio::sync::mpsc::UnboundedSender; /// Nostr events from relays (distinct from [`Event`] in `util::types`). type NostrEvents = BTreeSet; +fn has_mostro_event_kind(event: &nostr_sdk::prelude::Event, kind: u16) -> bool { + event.kind == nostr_sdk::prelude::Kind::Custom(kind) +} + +fn is_mostro_order_event(event: &nostr_sdk::prelude::Event, mostro_pubkey: PublicKey) -> bool { + event.pubkey == mostro_pubkey && has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) +} + +fn is_mostro_dispute_event(event: &nostr_sdk::prelude::Event, mostro_pubkey: PublicKey) -> bool { + event.pubkey == mostro_pubkey && has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) +} + /// Parse order from nostr tags pub fn order_from_tags(tags: Tags) -> Result { let mut order = SmallOrder::default(); @@ -332,6 +344,9 @@ pub fn parse_disputes_events(events: NostrEvents) -> Vec { let mut latest_by_id: HashMap = HashMap::new(); for event in events.iter() { + if !has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) { + continue; + } let mut dispute = match dispute_from_tags(event.tags.clone()) { Ok(d) => d, Err(e) => { @@ -370,6 +385,9 @@ pub fn aggregate_latest_orders_by_id(events: &NostrEvents) -> HashMap = HashMap::new(); for event in events.iter() { + if !has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) { + continue; + } let mut order = match order_from_tags(event.tags.clone()) { Ok(o) => o, Err(e) => { @@ -450,7 +468,7 @@ pub async fn fetch_mostro_order_events( .await?; Ok(events .into_iter() - .filter(|e| e.pubkey == mostro_pubkey) + .filter(|e| is_mostro_order_event(e, mostro_pubkey)) .collect()) } @@ -485,6 +503,60 @@ fn order_matches_currency_filter(order: &SmallOrder, currencies: Option<&[String .unwrap_or(true) } +fn latest_small_order_by_id( + events: &NostrEvents, + mostro_pubkey: PublicKey, + order_id: Uuid, +) -> Option { + events + .iter() + .filter(|event| is_mostro_order_event(event, mostro_pubkey)) + .filter_map(|event| { + let mut order = match order_from_tags(event.tags.clone()) { + Ok(order) => order, + Err(e) => { + log::warn!("Failed to parse order from tags: {:?}", e); + return None; + } + }; + if order.id != Some(order_id) { + return None; + } + order.created_at = Some(event.created_at.as_secs() as i64); + Some((event.created_at, order)) + }) + .max_by_key(|(created_at, _)| *created_at) + .map(|(_, order)| order) +} + +fn latest_dispute_by_id( + events: &NostrEvents, + mostro_pubkey: PublicKey, + dispute_id: Uuid, +) -> Option { + events + .iter() + .filter(|event| is_mostro_dispute_event(event, mostro_pubkey)) + .filter_map(|event| { + let mut dispute = match dispute_from_tags(event.tags.clone()) { + Ok(dispute) => dispute, + Err(e) => { + log::warn!("Failed to parse dispute from tags: {:?}", e); + return None; + } + }; + if dispute.id != dispute_id { + return None; + } + if dispute.created_at <= 0 { + dispute.created_at = event.created_at.as_secs() as i64; + } + Some((event.created_at, dispute)) + }) + .max_by_key(|(created_at, _)| *created_at) + .map(|(_, dispute)| dispute) +} + /// Fetch events list using the same logic as mostro-cli (adapted for mostrix) pub async fn fetch_events_list( list_kind: ListKind, @@ -508,7 +580,7 @@ pub async fn fetch_events_list( .timeout(FETCH_EVENTS_TIMEOUT) .await? .into_iter() - .filter(|e| e.pubkey == mostro_pubkey) + .filter(|e| is_mostro_dispute_event(e, mostro_pubkey)) .collect(); let disputes = parse_disputes_events(fetched_events); Ok(disputes.into_iter().map(Event::Dispute).collect()) @@ -582,14 +654,7 @@ pub async fn fetch_small_order_by_id_from_relay( .timeout(FETCH_EVENTS_TIMEOUT) .await .map_err(|e| anyhow::anyhow!("Failed to fetch order from relay by id: {}", e))?; - let Some(best) = events - .iter() - .filter(|e| e.pubkey == mostro_pubkey) - .max_by_key(|e| e.created_at) - else { - return Ok(None); - }; - Ok(Some(order_from_tags(best.tags.clone())?)) + Ok(latest_small_order_by_id(&events, mostro_pubkey, order_id)) } /// Fetch the latest kind-38386 [`Dispute`] for one dispute id (`d` tag). @@ -611,18 +676,7 @@ pub async fn fetch_dispute_by_id_from_relay( .timeout(FETCH_EVENTS_TIMEOUT) .await .map_err(|e| anyhow::anyhow!("Failed to fetch dispute from relay by id: {}", e))?; - let Some(best) = events - .iter() - .filter(|e| e.pubkey == mostro_pubkey) - .max_by_key(|e| e.created_at) - else { - return Ok(None); - }; - let mut dispute = dispute_from_tags(best.tags.clone())?; - if dispute.created_at <= 0 { - dispute.created_at = best.created_at.as_secs() as i64; - } - Ok(Some(dispute)) + Ok(latest_dispute_by_id(&events, mostro_pubkey, dispute_id)) } /// Fetch a single order's fiat code from the relay by order id (identifier "d" tag). @@ -820,13 +874,15 @@ pub(super) fn handle_mostro_response( mod tests { use super::{ admin_finalize_ack, dispute_from_tags, handle_mostro_response, - inferred_status_from_trade_action, is_terminal_trade_status, parse_disputes_events, + inferred_status_from_trade_action, is_terminal_trade_status, latest_dispute_by_id, + latest_small_order_by_id, parse_disputes_events, parse_orders_events, pending_orders_for_book, should_apply_status_transition, should_strictly_advance_status, AdminFinalizeAck, }; use crate::models::TERMINAL_ORDER_HISTORY_STATUSES; use mostro_core::prelude::{ Action, DisputeStatus, Message, SmallOrder, Status, NOSTR_DISPUTE_EVENT_KIND, + NOSTR_ORDER_EVENT_KIND, }; use nostr_sdk::prelude::*; use std::collections::BTreeSet; @@ -858,6 +914,31 @@ mod tests { .expect("dispute event") } + fn order_tags(id: Uuid, fiat_code: &str, status: &str) -> Tags { + Tags::from_list(vec![ + Tag::identifier(id.to_string()), + Tag::custom("k", vec!["buy".to_string()]), + Tag::custom("f", vec![fiat_code.to_string()]), + Tag::custom("s", vec![status.to_string()]), + Tag::custom("amt", vec!["1000".to_string()]), + ]) + } + + fn order_event( + keys: &Keys, + id: Uuid, + fiat_code: &str, + status: &str, + kind: u16, + published_at: u64, + ) -> Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags(order_tags(id, fiat_code, status)) + .custom_created_at(Timestamp::from(published_at)) + .finalize(keys) + .expect("order event") + } + #[test] fn dispute_from_tags_reads_created_at_open_time() { let id = Uuid::new_v4(); @@ -940,6 +1021,96 @@ mod tests { assert_eq!(filtered[0].id, Some(id)); } + #[test] + fn parse_orders_ignores_events_with_wrong_kind() { + let keys = Keys::generate(); + let id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + order_event(&keys, id, "USD", "pending", NOSTR_ORDER_EVENT_KIND + 1, 20), + order_event(&keys, id, "USD", "pending", NOSTR_ORDER_EVENT_KIND, 10), + ] + .into_iter() + .collect(); + + let parsed = parse_orders_events(events, None, Some(Status::Pending), None); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, Some(id)); + assert_eq!(parsed[0].created_at, Some(10)); + } + + #[test] + fn parse_disputes_ignores_events_with_wrong_kind() { + let keys = Keys::generate(); + let id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + EventBuilder::new(Kind::Custom(NOSTR_DISPUTE_EVENT_KIND + 1), "") + .tags(dispute_tags(id, "in-progress", Some(1_700_000_200))) + .custom_created_at(Timestamp::from(20)) + .finalize(&keys) + .expect("wrong kind dispute event"), + dispute_event(&keys, id, "initiated", Some(1_700_000_100), 10), + ] + .into_iter() + .collect(); + + let parsed = parse_disputes_events(events); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, id); + assert_eq!(parsed[0].status, DisputeStatus::Initiated.to_string()); + } + + #[test] + fn targeted_order_lookup_ignores_newer_event_with_wrong_id() { + let keys = Keys::generate(); + let wanted_id = Uuid::new_v4(); + let other_id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + order_event( + &keys, + wanted_id, + "USD", + "pending", + NOSTR_ORDER_EVENT_KIND, + 10, + ), + order_event( + &keys, + other_id, + "EUR", + "pending", + NOSTR_ORDER_EVENT_KIND, + 20, + ), + ] + .into_iter() + .collect(); + + let order = latest_small_order_by_id(&events, keys.public_key(), wanted_id).unwrap(); + + assert_eq!(order.id, Some(wanted_id)); + assert_eq!(order.fiat_code, "USD"); + } + + #[test] + fn targeted_dispute_lookup_ignores_newer_event_with_wrong_id() { + let keys = Keys::generate(); + let wanted_id = Uuid::new_v4(); + let other_id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + dispute_event(&keys, wanted_id, "initiated", Some(1_700_000_100), 10), + dispute_event(&keys, other_id, "in-progress", Some(1_700_000_200), 20), + ] + .into_iter() + .collect(); + + let dispute = latest_dispute_by_id(&events, keys.public_key(), wanted_id).unwrap(); + + assert_eq!(dispute.id, wanted_id); + assert_eq!(dispute.status, DisputeStatus::Initiated.to_string()); + } + #[test] fn parse_disputes_latest_revision_wins_by_event_stamp_not_open_tag() { let keys = Keys::generate(); From 3618e54855b7e54efbb041199862972dcf1398d6 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Thu, 20 Aug 2026 08:13:22 -0600 Subject: [PATCH 08/13] fix(data): make relay revision selection deterministic --- src/util/order_utils/helper.rs | 80 +++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 325f18bb..38765fab 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -523,10 +523,12 @@ fn latest_small_order_by_id( return None; } order.created_at = Some(event.created_at.as_secs() as i64); - Some((event.created_at, order)) + Some((event.created_at, event.id, order)) }) - .max_by_key(|(created_at, _)| *created_at) - .map(|(_, order)| order) + .max_by(|(a_ts, a_id, _), (b_ts, b_id, _)| { + compare_revision_keys(*a_ts, *a_id, *b_ts, *b_id) + }) + .map(|(_, _, order)| order) } fn latest_dispute_by_id( @@ -551,10 +553,22 @@ fn latest_dispute_by_id( if dispute.created_at <= 0 { dispute.created_at = event.created_at.as_secs() as i64; } - Some((event.created_at, dispute)) + Some((event.created_at, event.id, dispute)) + }) + .max_by(|(a_ts, a_id, _), (b_ts, b_id, _)| { + compare_revision_keys(*a_ts, *a_id, *b_ts, *b_id) }) - .max_by_key(|(created_at, _)| *created_at) - .map(|(_, dispute)| dispute) + .map(|(_, _, dispute)| dispute) +} + +fn compare_revision_keys( + a_ts: Timestamp, + a_id: EventId, + b_ts: Timestamp, + b_id: EventId, +) -> std::cmp::Ordering { + a_ts.cmp(&b_ts) + .then_with(|| b_id.as_bytes().cmp(a_id.as_bytes())) } /// Fetch events list using the same logic as mostro-cli (adapted for mostrix) @@ -1093,6 +1107,39 @@ mod tests { assert_eq!(order.fiat_code, "USD"); } + #[test] + fn targeted_order_lookup_breaks_equal_timestamp_ties_by_lower_event_id() { + let keys = Keys::generate(); + let order_id = Uuid::new_v4(); + let usd_event = order_event( + &keys, + order_id, + "USD", + "pending", + NOSTR_ORDER_EVENT_KIND, + 10, + ); + let eur_event = order_event( + &keys, + order_id, + "EUR", + "pending", + NOSTR_ORDER_EVENT_KIND, + 10, + ); + let expected_fiat = if usd_event.id.as_bytes() < eur_event.id.as_bytes() { + "USD" + } else { + "EUR" + }; + let events: BTreeSet<_> = [usd_event, eur_event].into_iter().collect(); + + let order = latest_small_order_by_id(&events, keys.public_key(), order_id).unwrap(); + + assert_eq!(order.id, Some(order_id)); + assert_eq!(order.fiat_code, expected_fiat); + } + #[test] fn targeted_dispute_lookup_ignores_newer_event_with_wrong_id() { let keys = Keys::generate(); @@ -1111,6 +1158,27 @@ mod tests { assert_eq!(dispute.status, DisputeStatus::Initiated.to_string()); } + #[test] + fn targeted_dispute_lookup_breaks_equal_timestamp_ties_by_lower_event_id() { + let keys = Keys::generate(); + let dispute_id = Uuid::new_v4(); + let initiated_event = + dispute_event(&keys, dispute_id, "initiated", Some(1_700_000_100), 10); + let in_progress_event = + dispute_event(&keys, dispute_id, "in-progress", Some(1_700_000_100), 10); + let expected_status = if initiated_event.id.as_bytes() < in_progress_event.id.as_bytes() { + DisputeStatus::Initiated.to_string() + } else { + DisputeStatus::InProgress.to_string() + }; + let events: BTreeSet<_> = [initiated_event, in_progress_event].into_iter().collect(); + + let dispute = latest_dispute_by_id(&events, keys.public_key(), dispute_id).unwrap(); + + assert_eq!(dispute.id, dispute_id); + assert_eq!(dispute.status, expected_status); + } + #[test] fn parse_disputes_latest_revision_wins_by_event_stamp_not_open_tag() { let keys = Keys::generate(); From a5dee191a6c6fbc685a768e9a3993202f9752c6e Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Thu, 20 Aug 2026 18:16:29 -0600 Subject: [PATCH 09/13] fix(ui): address order filter review edge cases --- src/main.rs | 26 +++++++++++++++++- src/ui/help_popup.rs | 4 +-- src/ui/key_handler/mod.rs | 3 ++- src/util/order_utils/helper.rs | 49 +++++++++++++++++++++++++++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5753c151..13bcc181 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,6 +216,7 @@ async fn drain_order_result_queue( } } +use crate::ui::orders::{OrderBookFilterField, OrderBookFilterState}; use crate::ui::{AppState, ChatAttachment, UiMode, UserRole}; use sqlx::SqlitePool; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; @@ -246,6 +247,13 @@ fn setup_logger(level: &str) -> Result<(), fern::InitError> { } fn apply_pasted_text_to_active_input(app: &mut AppState, pasted_text: &str) { + let filtered_text: String = pasted_text.chars().filter(|c| !c.is_control()).collect(); + + if let UiMode::OrderFilters(ref mut state) = app.mode { + append_paste_to_order_filter(state, &filtered_text); + return; + } + // Handle paste for invoice input if let UiMode::NewMessageNotification(_, Action::AddInvoice, ref mut invoice_state) = app.mode { if invoice_state.focused { @@ -264,7 +272,6 @@ fn apply_pasted_text_to_active_input(app: &mut AppState, pasted_text: &str) { // Handle paste for the Observer Shared key field if app.observer_inputs_editable() { - let filtered_text: String = pasted_text.chars().filter(|c| !c.is_control()).collect(); app.observer_shared_key_input.push_str(&filtered_text); } @@ -272,6 +279,23 @@ fn apply_pasted_text_to_active_input(app: &mut AppState, pasted_text: &str) { let _ = append_paste_to_admin_dispute_chat(app, pasted_text); } +fn append_paste_to_order_filter(state: &mut OrderBookFilterState, text: &str) { + let target = match state.focused { + OrderBookFilterField::Kind => None, + OrderBookFilterField::FiatCurrency => Some(&mut state.filters.fiat_code), + OrderBookFilterField::FiatAmountMin => Some(&mut state.filters.fiat_amount_min), + OrderBookFilterField::FiatAmountMax => Some(&mut state.filters.fiat_amount_max), + OrderBookFilterField::PremiumMin => Some(&mut state.filters.premium_min), + OrderBookFilterField::PremiumMax => Some(&mut state.filters.premium_max), + OrderBookFilterField::PaymentMethod => Some(&mut state.filters.payment_method), + OrderBookFilterField::CreatedWithinDays => Some(&mut state.filters.created_within_days), + }; + + if let Some(target) = target { + target.push_str(text); + } +} + /// Draws the TUI interface with tabs and active content. /// The "Orders" tab shows a table of pending orders and highlights the selected row. use crate::ui::ui_draw; diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 649dc603..104ecf31 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -233,7 +233,7 @@ fn compact_my_trades_help(narrow: bool) -> Vec> { fn compact_orders_help(narrow: bool) -> Vec> { if narrow { let (title_style, _) = settings_instruction_block_style(); - return ["↑↓ Enter", "Shift+F Shift+X"] + return ["↑↓", "Enter", "Shift+F", "Shift+X"] .into_iter() .map(|row| Line::from(Span::styled(row, title_style))) .collect(); @@ -599,7 +599,7 @@ mod help_content_tests { #[test] fn short_narrow_orders_help_keeps_filter_shortcuts_and_close_hint_visible() { - let backend = TestBackend::new(32, 8); + let backend = TestBackend::new(20, 10); let mut terminal = Terminal::new(backend).unwrap(); let app = AppState::new(UserRole::User); diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index eab25363..96d85e35 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -444,7 +444,8 @@ fn handle_clipboard_copy(text: String) -> bool { { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || linux_clipboard_copy_worker(text, tx)); - rx.recv().unwrap_or(false) + rx.recv_timeout(std::time::Duration::from_secs(2)) + .unwrap_or(false) } #[cfg(not(target_os = "linux"))] diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 38765fab..74d65a37 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -519,7 +519,7 @@ fn latest_small_order_by_id( return None; } }; - if order.id != Some(order_id) { + if order.id != Some(order_id) || order.kind.is_none() { return None; } order.created_at = Some(event.created_at.as_secs() as i64); @@ -938,6 +938,15 @@ mod tests { ]) } + fn order_tags_without_kind(id: Uuid, fiat_code: &str, status: &str) -> Tags { + Tags::from_list(vec![ + Tag::identifier(id.to_string()), + Tag::custom("f", vec![fiat_code.to_string()]), + Tag::custom("s", vec![status.to_string()]), + Tag::custom("amt", vec!["1000".to_string()]), + ]) + } + fn order_event( keys: &Keys, id: Uuid, @@ -953,6 +962,20 @@ mod tests { .expect("order event") } + fn order_event_without_kind( + keys: &Keys, + id: Uuid, + fiat_code: &str, + status: &str, + published_at: u64, + ) -> Event { + EventBuilder::new(Kind::Custom(NOSTR_ORDER_EVENT_KIND), "") + .tags(order_tags_without_kind(id, fiat_code, status)) + .custom_created_at(Timestamp::from(published_at)) + .finalize(keys) + .expect("order event without kind") + } + #[test] fn dispute_from_tags_reads_created_at_open_time() { let id = Uuid::new_v4(); @@ -1140,6 +1163,30 @@ mod tests { assert_eq!(order.fiat_code, expected_fiat); } + #[test] + fn targeted_order_lookup_rejects_order_without_kind() { + let keys = Keys::generate(); + let order_id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + order_event_without_kind(&keys, order_id, "EUR", "pending", 20), + order_event( + &keys, + order_id, + "USD", + "pending", + NOSTR_ORDER_EVENT_KIND, + 10, + ), + ] + .into_iter() + .collect(); + + let order = latest_small_order_by_id(&events, keys.public_key(), order_id).unwrap(); + + assert_eq!(order.id, Some(order_id)); + assert_eq!(order.fiat_code, "USD"); + } + #[test] fn targeted_dispute_lookup_ignores_newer_event_with_wrong_id() { let keys = Keys::generate(); From a6615050f8fddaac200526a8444887d67232cdad Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Mon, 24 Aug 2026 09:17:12 -0600 Subject: [PATCH 10/13] fix(ui): handle caps lock order filter shortcuts --- src/ui/key_handler/mod.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 96d85e35..a8032f4f 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -639,7 +639,12 @@ fn is_paste_shortcut(key_event: &KeyEvent) -> bool { fn is_shift_char_shortcut(key_event: &KeyEvent, lower: char, upper: char) -> bool { match key_event.code { KeyCode::Char(c) if c == upper => true, - KeyCode::Char(c) if c == lower => key_event.modifiers.contains(KeyModifiers::SHIFT), + KeyCode::Char(c) if c == lower => { + key_event.modifiers.contains(KeyModifiers::SHIFT) + || key_event + .state + .contains(crossterm::event::KeyEventState::CAPS_LOCK) + } _ => false, } } @@ -2015,6 +2020,16 @@ mod key_handler_tests { 'f', 'F' )); + assert!(is_shift_char_shortcut( + &KeyEvent::new_with_kind_and_state( + KeyCode::Char('f'), + KeyModifiers::NONE, + crossterm::event::KeyEventKind::Press, + crossterm::event::KeyEventState::CAPS_LOCK, + ), + 'f', + 'F' + )); assert!(!is_shift_char_shortcut( &KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), 'f', From 9df9423896c6a1d52dc8df8348cb8ea2c2cbc624 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Mon, 24 Aug 2026 09:42:37 -0600 Subject: [PATCH 11/13] fix(ui): handle caps lock order filter input --- src/ui/key_handler/mod.rs | 77 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index a8032f4f..dd1179cf 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -649,6 +649,18 @@ fn is_shift_char_shortcut(key_event: &KeyEvent, lower: char, upper: char) -> boo } } +fn is_order_filter_open_shortcut(key_event: &KeyEvent) -> bool { + is_shift_char_shortcut(key_event, 'f', 'F') + || matches!(key_event.code, KeyCode::Char('f')) + && !key_event.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) +} + fn update_invoice_notification_action_selection( code: KeyCode, invoice_state: &mut crate::ui::InvoiceInputState, @@ -679,6 +691,10 @@ fn active_order_filter_input(state: &mut OrderBookFilterState) -> Option<&mut St } } +fn order_filter_field_accepts_text(field: OrderBookFilterField) -> bool { + !matches!(field, OrderBookFilterField::Kind) +} + fn handle_order_filter_popup_key(app: &mut AppState, code: KeyCode, key_event: &KeyEvent) -> bool { let UiMode::OrderFilters(mut state) = app.mode.clone() else { return false; @@ -710,7 +726,10 @@ fn handle_order_filter_popup_key(app: &mut AppState, code: KeyCode, key_event: & state.filters.kind.cycle(); app.mode = UiMode::OrderFilters(state); } - KeyCode::Char('x') | KeyCode::Char('X') if is_shift_char_shortcut(key_event, 'x', 'X') => { + KeyCode::Char('x') | KeyCode::Char('X') + if !order_filter_field_accepts_text(state.focused) + && is_shift_char_shortcut(key_event, 'x', 'X') => + { state.filters = OrderBookFilters::default(); app.mode = UiMode::OrderFilters(state); } @@ -1537,7 +1556,7 @@ pub fn handle_key_event( if let Tab::User(UserTab::Orders) = app.active_tab { match code { KeyCode::Char('f') | KeyCode::Char('F') - if is_shift_char_shortcut(&key_event, 'f', 'F') => + if is_order_filter_open_shortcut(&key_event) => { if matches!( app.mode, @@ -1961,7 +1980,7 @@ mod key_handler_tests { payment_method: "cash".to_string(), ..Default::default() }, - focused: OrderBookFilterField::FiatCurrency, + focused: OrderBookFilterField::Kind, }); let handled = handle_order_filter_popup_key( @@ -1989,7 +2008,7 @@ mod key_handler_tests { payment_method: "cash".to_string(), ..Default::default() }, - focused: OrderBookFilterField::FiatCurrency, + focused: OrderBookFilterField::Kind, }); let handled = handle_order_filter_popup_key( @@ -2008,6 +2027,32 @@ mod key_handler_tests { } } + #[test] + fn uppercase_x_appends_to_text_order_filter_field() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters { + fiat_code: "M".to_string(), + ..Default::default() + }, + focused: OrderBookFilterField::FiatCurrency, + }); + + let handled = handle_order_filter_popup_key( + &mut app, + KeyCode::Char('X'), + &KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT), + ); + + assert!(handled); + match app.mode { + UiMode::OrderFilters(state) => { + assert_eq!(state.filters.fiat_code, "MX"); + } + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + #[test] fn order_filter_shortcut_detection_accepts_shift_or_uppercase() { assert!(is_shift_char_shortcut( @@ -2037,6 +2082,30 @@ mod key_handler_tests { )); } + #[test] + fn order_filter_open_shortcut_accepts_plain_f_for_caps_lock_terminals() { + assert!(is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::NONE + ))); + assert!(is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('F'), + KeyModifiers::NONE + ))); + assert!(is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::SHIFT + ))); + } + + #[test] + fn order_filter_open_shortcut_ignores_control_f() { + assert!(!is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::CONTROL + ))); + } + #[test] fn trade_action_shortcuts_open_the_right_confirmation_when_interactive() { let order_id = uuid::Uuid::new_v4(); From 1194427ec1071cb796d2f362ec70a28a99351ee8 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Mon, 24 Aug 2026 10:04:19 -0600 Subject: [PATCH 12/13] fix(ui): handle constrained order filter help shortcuts --- src/ui/help_popup.rs | 29 +++++++++++++++++++++++++++-- src/ui/key_handler/mod.rs | 24 +++++++++++++++--------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 104ecf31..d4af6676 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -73,7 +73,7 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { if matches!(tab, Tab::Admin(AdminTab::DisputesInProgress)) { lines.push(help_disputes_in_progress_intro()); } else if compact_orders { - lines.extend(compact_orders_help(narrow_orders)); + lines.extend(compact_orders_help(narrow_orders, inner.height)); } else if compact_my_trades { lines.extend(compact_my_trades_help(narrow_my_trades)); } else if matches!(tab, Tab::User(UserTab::Orders)) { @@ -230,9 +230,15 @@ fn compact_my_trades_help(narrow: bool) -> Vec> { .collect() } -fn compact_orders_help(narrow: bool) -> Vec> { +fn compact_orders_help(narrow: bool, inner_height: u16) -> Vec> { if narrow { let (title_style, _) = settings_instruction_block_style(); + if inner_height <= 4 { + return ["Shift+F", "Shift+X"] + .into_iter() + .map(|row| Line::from(Span::styled(row, title_style))) + .collect(); + } return ["↑↓", "Enter", "Shift+F", "Shift+X"] .into_iter() .map(|row| Line::from(Span::styled(row, title_style))) @@ -621,4 +627,23 @@ mod help_content_tests { ); } } + + #[test] + fn very_short_narrow_orders_help_keeps_filters_and_close_hint_visible() { + let backend = TestBackend::new(20, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let app = AppState::new(UserRole::User); + + terminal + .draw(|f| render_help_popup(f, &app, Tab::User(UserTab::Orders))) + .unwrap(); + + let buf = terminal.backend().buffer(); + for expected in ["Shift+F", "Shift+X", "Esc, Enter or", "Ctrl+H to close"] { + assert!( + buffer_contains(buf, expected), + "missing {expected:?} from very short Orders help" + ); + } + } } diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index dd1179cf..1255b95a 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -650,15 +650,17 @@ fn is_shift_char_shortcut(key_event: &KeyEvent, lower: char, upper: char) -> boo } fn is_order_filter_open_shortcut(key_event: &KeyEvent) -> bool { - is_shift_char_shortcut(key_event, 'f', 'F') - || matches!(key_event.code, KeyCode::Char('f')) - && !key_event.modifiers.intersects( - KeyModifiers::CONTROL - | KeyModifiers::ALT - | KeyModifiers::SUPER - | KeyModifiers::HYPER - | KeyModifiers::META, - ) + if key_event.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) { + return false; + } + + is_shift_char_shortcut(key_event, 'f', 'F') || matches!(key_event.code, KeyCode::Char('f')) } fn update_invoice_notification_action_selection( @@ -2104,6 +2106,10 @@ mod key_handler_tests { KeyCode::Char('f'), KeyModifiers::CONTROL ))); + assert!(!is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('F'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ))); } #[test] From 3ba963018a3785ffaf5f510f81f4b1c46b63e454 Mon Sep 17 00:00:00 2001 From: Carlos Ruz Date: Fri, 4 Sep 2026 17:58:37 -0600 Subject: [PATCH 13/13] fix(ui): handle order filter review edge cases --- src/ui/help_popup.rs | 57 +++++++++++++++-- src/ui/key_handler/mod.rs | 108 ++++++++++++++++++++++++++++++++ src/util/order_utils/helper.rs | 109 +++++++++++++++++++++++++++++++-- 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index d4af6676..c76b01c6 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -73,7 +73,11 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { if matches!(tab, Tab::Admin(AdminTab::DisputesInProgress)) { lines.push(help_disputes_in_progress_intro()); } else if compact_orders { - lines.extend(compact_orders_help(narrow_orders, inner.height)); + lines.extend(compact_orders_help( + narrow_orders, + inner.width, + inner.height, + )); } else if compact_my_trades { lines.extend(compact_my_trades_help(narrow_my_trades)); } else if matches!(tab, Tab::User(UserTab::Orders)) { @@ -88,8 +92,13 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { lines.push(help_shortcut_line(&s)); } } + let close_hint = if compact_orders { + compact_orders_close_hint(inner.width) + } else { + HELP_CLOSE_HINT + }; lines.push(Line::from(Span::styled( - HELP_CLOSE_HINT, + close_hint, Style::default().fg(Color::DarkGray), ))); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: true }); @@ -230,10 +239,12 @@ fn compact_my_trades_help(narrow: bool) -> Vec> { .collect() } -fn compact_orders_help(narrow: bool, inner_height: u16) -> Vec> { +fn compact_orders_help(narrow: bool, inner_width: u16, inner_height: u16) -> Vec> { if narrow { let (title_style, _) = settings_instruction_block_style(); - if inner_height <= 4 { + let close_rows = if inner_width <= 13 { 1 } else { 2 }; + let available_shortcut_rows = inner_height.saturating_sub(close_rows); + if available_shortcut_rows <= 3 { return ["Shift+F", "Shift+X"] .into_iter() .map(|row| Line::from(Span::styled(row, title_style))) @@ -254,6 +265,14 @@ fn compact_orders_help(narrow: bool, inner_height: u16) -> Vec> { .collect() } +fn compact_orders_close_hint(inner_width: u16) -> &'static str { + if inner_width <= 13 { + "Esc" + } else { + HELP_CLOSE_HINT + } +} + /// Split `Key: description` help strings into bold key + gray body (same as Settings Shift+H rows). fn help_shortcut_line(s: &str) -> Line<'static> { let (title_style, body_style) = settings_instruction_block_style(); @@ -507,7 +526,7 @@ mod help_content_tests { use ratatui::backend::TestBackend; use ratatui::Terminal; - fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { let mut flat = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { @@ -515,7 +534,11 @@ mod help_content_tests { } flat.push('\n'); } - flat.contains(needle) + flat + } + + fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + buffer_text(buf).contains(needle) } #[test] @@ -646,4 +669,26 @@ mod help_content_tests { ); } } + + #[test] + fn tiny_narrow_orders_help_keeps_filters_and_short_close_hint_visible() { + for (width, height) in [(15, 10), (15, 6)] { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let app = AppState::new(UserRole::User); + + terminal + .draw(|f| render_help_popup(f, &app, Tab::User(UserTab::Orders))) + .unwrap(); + + let buf = terminal.backend().buffer(); + for expected in ["Shift+F", "Shift+X", "Esc"] { + let rendered = buffer_text(buf); + assert!( + rendered.contains(expected), + "missing {expected:?} from {width}x{height} Orders help:\n{rendered}" + ); + } + } + } } diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 1255b95a..a91ab369 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -693,6 +693,39 @@ fn active_order_filter_input(state: &mut OrderBookFilterState) -> Option<&mut St } } +fn paste_text_into_order_filter(state: &mut OrderBookFilterState, text: &str) -> bool { + let filtered_text: String = text.chars().filter(|c| !c.is_control()).collect(); + if filtered_text.is_empty() { + return false; + } + + let Some(input) = active_order_filter_input(state) else { + return false; + }; + input.push_str(&filtered_text); + true +} + +fn handle_order_filter_paste_shortcut( + app: &mut AppState, + key_event: &KeyEvent, + mut read_clipboard: F, +) -> Option +where + F: FnMut() -> Option, +{ + if !matches!(app.mode, UiMode::OrderFilters(_)) || !is_paste_shortcut(key_event) { + return None; + } + + if let Some(text) = read_clipboard() { + if let UiMode::OrderFilters(ref mut state) = app.mode { + paste_text_into_order_filter(state, &text); + } + } + Some(true) +} + fn order_filter_field_accepts_text(field: OrderBookFilterField) -> bool { !matches!(field, OrderBookFilterField::Kind) } @@ -784,6 +817,12 @@ pub fn handle_key_event( // Clear transient attachment toast on any key press app.attachment_toast = None; + if let Some(handled) = + handle_order_filter_paste_shortcut(app, &key_event, read_clipboard_text_best_effort) + { + return Some(handled); + } + if matches!(app.mode, UiMode::OrderFilters(_)) { return Some(handle_order_filter_popup_key(app, code, &key_event)); } @@ -2055,6 +2094,75 @@ mod key_handler_tests { } } + #[test] + fn ctrl_v_pastes_into_focused_order_filter_field() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters { + fiat_code: "M".to_string(), + ..Default::default() + }, + focused: OrderBookFilterField::FiatCurrency, + }); + + let handled = handle_order_filter_paste_shortcut( + &mut app, + &KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + || Some("XN\n".to_string()), + ); + + assert_eq!(handled, Some(true)); + match app.mode { + UiMode::OrderFilters(state) => assert_eq!(state.filters.fiat_code, "MXN"), + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + + #[test] + fn shift_insert_pastes_into_focused_order_filter_field() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters { + payment_method: "SPE".to_string(), + ..Default::default() + }, + focused: OrderBookFilterField::PaymentMethod, + }); + + let handled = handle_order_filter_paste_shortcut( + &mut app, + &KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT), + || Some("I\n".to_string()), + ); + + assert_eq!(handled, Some(true)); + match app.mode { + UiMode::OrderFilters(state) => assert_eq!(state.filters.payment_method, "SPEI"), + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + + #[test] + fn order_filter_paste_shortcut_ignores_non_text_field() { + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::OrderFilters(OrderBookFilterState { + filters: OrderBookFilters::default(), + focused: OrderBookFilterField::Kind, + }); + + let handled = handle_order_filter_paste_shortcut( + &mut app, + &KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + || Some("sell".to_string()), + ); + + assert_eq!(handled, Some(true)); + match app.mode { + UiMode::OrderFilters(state) => assert_eq!(state.filters, OrderBookFilters::default()), + other => panic!("expected OrderFilters mode, got {other:?}"), + } + } + #[test] fn order_filter_shortcut_detection_accepts_shift_or_uppercase() { assert!(is_shift_char_shortcut( diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 74d65a37..74521b8c 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -23,12 +23,20 @@ fn has_mostro_event_kind(event: &nostr_sdk::prelude::Event, kind: u16) -> bool { event.kind == nostr_sdk::prelude::Kind::Custom(kind) } +fn has_valid_event_signature(event: &nostr_sdk::prelude::Event) -> bool { + event.verify().is_ok() +} + fn is_mostro_order_event(event: &nostr_sdk::prelude::Event, mostro_pubkey: PublicKey) -> bool { - event.pubkey == mostro_pubkey && has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) + event.pubkey == mostro_pubkey + && has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) + && has_valid_event_signature(event) } fn is_mostro_dispute_event(event: &nostr_sdk::prelude::Event, mostro_pubkey: PublicKey) -> bool { - event.pubkey == mostro_pubkey && has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) + event.pubkey == mostro_pubkey + && has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) + && has_valid_event_signature(event) } /// Parse order from nostr tags @@ -344,7 +352,9 @@ pub fn parse_disputes_events(events: NostrEvents) -> Vec { let mut latest_by_id: HashMap = HashMap::new(); for event in events.iter() { - if !has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) { + if !has_mostro_event_kind(event, NOSTR_DISPUTE_EVENT_KIND) + || !has_valid_event_signature(event) + { continue; } let mut dispute = match dispute_from_tags(event.tags.clone()) { @@ -385,7 +395,9 @@ pub fn aggregate_latest_orders_by_id(events: &NostrEvents) -> HashMap = HashMap::new(); for event in events.iter() { - if !has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) { + if !has_mostro_event_kind(event, NOSTR_ORDER_EVENT_KIND) + || !has_valid_event_signature(event) + { continue; } let mut order = match order_from_tags(event.tags.clone()) { @@ -976,6 +988,11 @@ mod tests { .expect("order event without kind") } + fn tamper_event_content(mut event: Event) -> Event { + event.content.push_str("tampered"); + event + } + #[test] fn dispute_from_tags_reads_created_at_open_time() { let id = Uuid::new_v4(); @@ -1098,6 +1115,55 @@ mod tests { assert_eq!(parsed[0].status, DisputeStatus::Initiated.to_string()); } + #[test] + fn parse_orders_rejects_tampered_events() { + let keys = Keys::generate(); + let id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + tamper_event_content(order_event( + &keys, + id, + "EUR", + "pending", + NOSTR_ORDER_EVENT_KIND, + 20, + )), + order_event(&keys, id, "USD", "pending", NOSTR_ORDER_EVENT_KIND, 10), + ] + .into_iter() + .collect(); + + let parsed = parse_orders_events(events, None, Some(Status::Pending), None); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, Some(id)); + assert_eq!(parsed[0].fiat_code, "USD"); + } + + #[test] + fn parse_disputes_rejects_tampered_events() { + let keys = Keys::generate(); + let id = Uuid::new_v4(); + let events: BTreeSet<_> = [ + tamper_event_content(dispute_event( + &keys, + id, + "in-progress", + Some(1_700_000_200), + 20, + )), + dispute_event(&keys, id, "initiated", Some(1_700_000_100), 10), + ] + .into_iter() + .collect(); + + let parsed = parse_disputes_events(events); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, id); + assert_eq!(parsed[0].status, DisputeStatus::Initiated.to_string()); + } + #[test] fn targeted_order_lookup_ignores_newer_event_with_wrong_id() { let keys = Keys::generate(); @@ -1130,6 +1196,24 @@ mod tests { assert_eq!(order.fiat_code, "USD"); } + #[test] + fn targeted_order_lookup_rejects_tampered_event() { + let keys = Keys::generate(); + let order_id = Uuid::new_v4(); + let events: BTreeSet<_> = [tamper_event_content(order_event( + &keys, + order_id, + "USD", + "pending", + NOSTR_ORDER_EVENT_KIND, + 10, + ))] + .into_iter() + .collect(); + + assert!(latest_small_order_by_id(&events, keys.public_key(), order_id).is_none()); + } + #[test] fn targeted_order_lookup_breaks_equal_timestamp_ties_by_lower_event_id() { let keys = Keys::generate(); @@ -1205,6 +1289,23 @@ mod tests { assert_eq!(dispute.status, DisputeStatus::Initiated.to_string()); } + #[test] + fn targeted_dispute_lookup_rejects_tampered_event() { + let keys = Keys::generate(); + let dispute_id = Uuid::new_v4(); + let events: BTreeSet<_> = [tamper_event_content(dispute_event( + &keys, + dispute_id, + "initiated", + Some(1_700_000_100), + 10, + ))] + .into_iter() + .collect(); + + assert!(latest_dispute_by_id(&events, keys.public_key(), dispute_id).is_none()); + } + #[test] fn targeted_dispute_lookup_breaks_equal_timestamp_ties_by_lower_event_id() { let keys = Keys::generate();