diff --git a/src/main.rs b/src/main.rs index e7d4d2f8..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; @@ -960,6 +984,15 @@ 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 + && matches!( + app.active_tab, + crate::ui::navigation::Tab::User(crate::ui::navigation::UserTab::Orders) + ) { + " | 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 +1009,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/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/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/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/help_popup.rs b/src/ui/help_popup.rs index 670663a9..c76b01c6 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,18 +72,33 @@ 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, + 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)) { + 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)); } } + 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 }); @@ -216,6 +239,40 @@ fn compact_my_trades_help(narrow: bool) -> Vec> { .collect() } +fn compact_orders_help(narrow: bool, inner_width: u16, inner_height: u16) -> Vec> { + if narrow { + let (title_style, _) = settings_instruction_block_style(); + 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))) + .collect(); + } + 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() +} + +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(); @@ -408,6 +465,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) => ( @@ -467,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 { @@ -475,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] @@ -562,4 +625,70 @@ mod help_content_tests { ); } } + + #[test] + fn short_narrow_orders_help_keeps_filter_shortcuts_and_close_hint_visible() { + let backend = TestBackend::new(20, 10); + 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" + ); + } + } + + #[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" + ); + } + } + + #[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/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/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..a91ab369 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, @@ -443,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"))] @@ -634,6 +636,33 @@ 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) + || key_event + .state + .contains(crossterm::event::KeyEventState::CAPS_LOCK) + } + _ => false, + } +} + +fn is_order_filter_open_shortcut(key_event: &KeyEvent) -> bool { + 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( code: KeyCode, invoice_state: &mut crate::ui::InvoiceInputState, @@ -651,6 +680,115 @@ 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 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) +} + +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 !order_filter_field_accepts_text(state.focused) + && is_shift_char_shortcut(key_event, 'x', 'X') => + { + 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 +817,16 @@ 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)); + } + // 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 +1594,39 @@ pub fn handle_key_event( } } + if let Tab::User(UserTab::Orders) = app.active_tab { + match code { + KeyCode::Char('f') | KeyCode::Char('F') + if is_order_filter_open_shortcut(&key_event) => + { + 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 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); + } + } + _ => {} + } + } + // 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 @@ -1831,6 +2012,214 @@ 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::Kind, + }); + + 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 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::Kind, + }); + + 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 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 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( + &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_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', + 'F' + )); + } + + #[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 + ))); + assert!(!is_order_filter_open_shortcut(&KeyEvent::new( + KeyCode::Char('F'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ))); + } + #[test] fn trade_action_shortcuts_open_the_right_confirmation_when_interactive() { let order_id = uuid::Uuid::new_v4(); 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(_) 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 { diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index 1e0dc192..a4dcde88 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,17 @@ 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); + 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 currency filter…", + "πŸ“­ No offers match the current filters…", Style::default().fg(Color::Yellow), )) .block( @@ -80,17 +88,17 @@ 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 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,345 @@ pub fn render_orders_tab( ); } +fn split_filter_and_table(area: Rect, has_filters: bool) -> (Option, Rect) { + if !has_filters || area.height < 7 { + return (None, area); + } + let chunks = Layout::new( + Direction::Vertical, + [Constraint::Length(4), 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 = vec![ + Line::from(vec![ + 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))), + ]; + 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, + 76.min(area.width.saturating_sub(2)), + 16.min(area.height), + ); + 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, inner_width, inner_height) + } else { + 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)), + ), + popup, + ); +} + +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, inner_width), + Line::from(""), + ]; + lines.extend( + OrderBookFilterField::ALL + .iter() + .map(|field| filter_field_line(state, *field, label_width, value_width)), + ); + lines.push(Line::from("")); + lines.push(focused_field_hint_line(state.focused, inner_width)); + lines +} + +fn compact_order_filter_popup_lines( + state: &OrderBookFilterState, + inner_width: usize, + inner_height: u16, +) -> Vec> { + let label = state.focused.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(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + + if inner_height > 5 { + lines.push(focused_field_hint_line(state.focused, inner_width)); + } + + lines +} + +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( + truncate_for_cell(&state.filters.summary(), summary_width) + .trim_end() + .to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(Span::styled( + truncate_for_cell( + "Active: no filters. Set fields below, then press Enter.", + inner_width, + ) + .trim_end() + .to_string(), + 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, + label_width: usize, + value_width: usize, +) -> Line<'static> { + Line::from(filter_field_spans(state, field, label_width, value_width)) +} + +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, 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.", + 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( + 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 { + out.pop(); + out.push('.'); + } + 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(), + 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::*; @@ -294,6 +636,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] @@ -306,6 +649,147 @@ 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")); + assert!(buffer_contains(buf, "Shift+F: edit filters")); + } + + #[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); + 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, "Shift+X Clear")); + 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")); + } + + #[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] @@ -320,8 +804,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 +837,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 +920,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)) diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 4901388f..74521b8c 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -19,6 +19,26 @@ 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 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) + && 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) + && has_valid_event_signature(event) +} + /// Parse order from nostr tags pub fn order_from_tags(tags: Tags) -> Result { let mut order = SmallOrder::default(); @@ -332,6 +352,11 @@ 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) + || !has_valid_event_signature(event) + { + continue; + } let mut dispute = match dispute_from_tags(event.tags.clone()) { Ok(d) => d, Err(e) => { @@ -370,6 +395,11 @@ 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) + || !has_valid_event_signature(event) + { + continue; + } let mut order = match order_from_tags(event.tags.clone()) { Ok(o) => o, Err(e) => { @@ -419,10 +449,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() @@ -453,7 +480,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()) } @@ -469,10 +496,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 +504,85 @@ 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) +} + +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) || order.kind.is_none() { + return None; + } + order.created_at = Some(event.created_at.as_secs() as i64); + Some((event.created_at, event.id, 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( + 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, event.id, dispute)) + }) + .max_by(|(a_ts, a_id, _), (b_ts, b_id, _)| { + compare_revision_keys(*a_ts, *a_id, *b_ts, *b_id) + }) + .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) pub async fn fetch_events_list( list_kind: ListKind, @@ -503,7 +606,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()) @@ -577,14 +680,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). @@ -606,18 +702,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). @@ -815,11 +900,16 @@ 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, - should_apply_status_transition, should_strictly_advance_status, AdminFinalizeAck, + 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, Status, NOSTR_DISPUTE_EVENT_KIND}; + 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; use std::str::FromStr; @@ -850,6 +940,59 @@ 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_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, + 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") + } + + 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") + } + + 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(); @@ -912,6 +1055,278 @@ 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_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 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(); + 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_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(); + 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_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(); + 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 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(); + 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();