diff --git a/Cargo.lock b/Cargo.lock index b3369c2..cd2f327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2099,6 +2099,7 @@ dependencies = [ "dirs", "fern", "futures", + "libc", "lightning-invoice", "lnurl-rs", "log", @@ -2108,6 +2109,7 @@ dependencies = [ "reqwest", "rustls", "serde", + "serde_json", "sqlx", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 5e85757..da0d006 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,12 +23,14 @@ fern = "0.7.1" log = "0.4.27" config = { version = "0.15.11", features = ["toml"] } serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0" toml = "0.9.8" base64 = "0.22.1" uuid = { version = "1.0", features = ["v4", "serde"] } lightning-invoice = { version = "0.34.0", features = ["std"] } lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] } arboard = "3.3" +libc = "0.2" reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "http2"] } rustls = { version = "0.23", features = ["ring"] } chacha20poly1305 = "0.10" diff --git a/debug-notes.md b/debug-notes.md new file mode 100644 index 0000000..90939e6 --- /dev/null +++ b/debug-notes.md @@ -0,0 +1,62 @@ +# Debug Handoff Notes (DM subscriptions / missing take-order notifications) + +## Branch / scope +- Branch: `fix-windows-launch` +- Focus area: `src/util/dm_utils/mod.rs` + take/new order subscription timing +- Goal: ensure take-order/new-order flows always produce DM notifications without missing first events + +## Current status +- Subscription model for DM listener is in place (`client.notifications()` + dynamic subscribe commands). +- Runtime logs showed at least one reproducible miss: + - GiftWrap arrived with an unknown `subscription_id` before/without listener map entry. + - Listener dropped it previously, causing missing notification. + +## Key runtime evidence seen +- Example from `app.log`: + - `Taking order ... trade index ...` + - `[dm_listener] Ignoring GiftWrap for unknown subscription_id=...` + - then later: + - `[take_order] Sending DM subscription command ...` + - `[dm_listener] Received subscribe command ...` + - `[dm_listener] Subscribed GiftWrap: subscription_id=...` + +Interpretation: first event can arrive before/under different subscription context than tracked by listener map. + +## Instrumentation currently present +- `take_order.rs`: + - logs mapping of response payload IDs and effective order id + - logs early and post-response subscription command sends +- `dm_utils/mod.rs` listener: + - logs command receipt + - logs successful subscribe + subscription_id + - logs unknown subscription_id events + - logs routed/parsed/handled messages + - logs terminal-status cleanup + +## Changes already applied during debug +1. **Early subscribe in `take_order`** + - subscription command sent immediately after deriving trade key/index, before waiting for Mostro reply. +2. **Unknown-subscription fallback path in listener** + - for GiftWrap with unknown `subscription_id`, try active trade keys and parse/decrypt. + - if parse succeeds, route message to matched `(order_id, trade_index)`. +3. **Additional hardening from earlier cycles** + - lock-order deadlock fix (`messages` vs `pending_notifications`) + - keep latest per-order message row correctly when same timestamp/different action + - `wait_for_dm` filters by `subscription_id` instead of `event.pubkey` + - terminal-status cleanup unsubscribes/removes tracked order + +## What to test next (first thing tomorrow) +1. Clear `app.log`. +2. Run app, reproduce take-order notification miss. +3. Inspect `app.log` for this sequence: + - `[take_order] Early subscribe command ...` + - `[dm_listener] Received subscribe command ...` + - `[dm_listener] Subscribed GiftWrap ...` + - if unknown id still appears: + - `[dm_listener] Unknown subscription_id..., trying active trade-key fallback` + - `[dm_listener] Fallback routed GiftWrap ...` OR `Fallback failed ...` +4. Confirm whether UI now shows notification/pop-up. + +## Open question +- If fallback still fails, next hypothesis is not subscription timing but parse/decrypt mismatch for that specific event/key path (need event metadata + parse counts from logs to isolate). + diff --git a/docs/MESSAGE_FLOW_AND_PROTOCOL.md b/docs/MESSAGE_FLOW_AND_PROTOCOL.md index b998b5d..0f4c739 100644 --- a/docs/MESSAGE_FLOW_AND_PROTOCOL.md +++ b/docs/MESSAGE_FLOW_AND_PROTOCOL.md @@ -55,7 +55,7 @@ sequenceDiagram app.mode = UiMode::UserMode(UserMode::WaitingForMostro(form_clone.clone())); ``` -The user fills out the order form and confirms with the `y` key. The UI switches to `WaitingForMostro` mode. +The user fills out the order form and confirms with **Enter** on the \"Create New Order\" form. The UI switches to `WaitingForMostro` mode. ### 2. Trade Key Derivation **Source**: `src/util/order_utils/send_new_order.rs:84` diff --git a/src/main.rs b/src/main.rs index 3783e22..64e6a0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use crate::ui::key_handler::{ }; use crate::ui::{AdminChatLastSeen, ChatParty, MostroInfoFetchResult, OperationResult}; use crate::util::{ - fetch_mostro_instance_info, handle_message_notification, handle_order_result, + fetch_mostro_instance_info, handle_message_notification, handle_operation_result, listen_for_order_messages, order_utils::{spawn_admin_chat_fetch, start_fetch_scheduler, FetchSchedulerResult}, spawn_save_attachment, @@ -283,6 +283,8 @@ async fn main() -> Result<(), anyhow::Error> { mut save_attachment_rx, mostro_info_tx, mut mostro_info_rx, + mut dm_subscription_tx, + dm_subscription_rx, } = create_app_channels(); // Admin chat keys (for trade-key send/fetch); only set when admin mode @@ -307,6 +309,7 @@ async fn main() -> Result<(), anyhow::Error> { messages_clone, message_notification_tx_clone, pending_notifications_clone, + dm_subscription_rx, ) .await; }); @@ -320,7 +323,7 @@ async fn main() -> Result<(), anyhow::Error> { if (msg.contains("Dispute") && msg.contains("taken successfully")) || (msg.contains("Dispute") && (msg.contains("settled") || msg.contains("canceled")))); - handle_order_result(result, &mut app); + handle_operation_result(result, &mut app); // If this is an Info result about taking or finalizing a dispute, refresh the disputes list if is_dispute_related && app.user_role == UserRole::Admin { @@ -481,6 +484,7 @@ async fn main() -> Result<(), anyhow::Error> { &validate_range_amount, admin_chat_keys.as_ref(), Some(&save_attachment_tx), + &dm_subscription_tx, ) { Some(true) => { if app.pending_key_reload { @@ -496,6 +500,7 @@ async fn main() -> Result<(), anyhow::Error> { Arc::clone(&disputes), &mut order_task, &mut dispute_task, + &mut dm_subscription_tx, ) .await; } diff --git a/src/models.rs b/src/models.rs index 8332e78..6223eb3 100644 --- a/src/models.rs +++ b/src/models.rs @@ -170,12 +170,16 @@ pub struct Order { } impl Order { - /// Create a new order from SmallOrder and save it to the database + /// Create a new order from SmallOrder and save it to the database. + /// + /// `is_maker`: `true` if the local user **created** the order (maker); `false` if they **took** + /// an existing order from the book (taker). Stored as `is_mine`. pub async fn new( pool: &SqlitePool, order: mostro_core::prelude::SmallOrder, trade_keys: &nostr_sdk::prelude::Keys, _request_id: Option, + is_maker: bool, ) -> Result { let trade_keys_hex = trade_keys.secret_key().to_secret_hex(); @@ -196,7 +200,7 @@ impl Order { premium: order.premium, trade_keys: Some(trade_keys_hex), counterparty_pubkey: None, - is_mine: Some(true), + is_mine: Some(is_maker), buyer_invoice: order.buyer_invoice, request_id: _request_id, created_at: Some(chrono::Utc::now().timestamp()), @@ -288,6 +292,105 @@ impl Order { Ok(()) } + /// Insert or update an order from a trade DM (e.g. `AddInvoice` with `waiting-buyer-invoice`). + /// + /// Does not update `users.last_trade_index` (unlike [`crate::util::db_utils::save_order`]). + /// Preserves `created_at` and selected fields when a row already exists. + /// + /// `is_mine` is taken from an existing row when present; otherwise defaults to `true` (maker) + /// for a brand-new DM-only insert (rare race before [`save_order`]). + pub async fn upsert_from_small_order_dm( + pool: &SqlitePool, + order_id_fallback: uuid::Uuid, + mut small_order: mostro_core::prelude::SmallOrder, + trade_keys: &nostr_sdk::prelude::Keys, + message_request_id: Option, + ) -> Result { + let resolved_id = small_order.id.unwrap_or(order_id_fallback); + small_order.id = Some(resolved_id); + let id_str = resolved_id.to_string(); + let so = small_order.clone(); + + let existing = Self::get_by_id(pool, &id_str).await.ok(); + + let trade_keys_hex = trade_keys.secret_key().to_secret_hex(); + + let created_at = existing + .as_ref() + .and_then(|e| e.created_at) + .or_else(|| Some(Utc::now().timestamp())); + + let request_id = + message_request_id.or_else(|| existing.as_ref().and_then(|e| e.request_id)); + + let order_row = Order { + id: Some(id_str.clone()), + kind: so.kind.as_ref().map(|k| k.to_string()), + status: so.status.as_ref().map(|s| s.to_string()), + amount: so.amount, + fiat_code: so.fiat_code, + min_amount: so.min_amount, + max_amount: so.max_amount, + fiat_amount: so.fiat_amount, + payment_method: so.payment_method, + premium: so.premium, + trade_keys: Some(trade_keys_hex), + counterparty_pubkey: existing + .as_ref() + .and_then(|e| e.counterparty_pubkey.clone()), + is_mine: existing.as_ref().and_then(|e| e.is_mine).or(Some(true)), + buyer_invoice: so.buyer_invoice, + request_id, + created_at, + expires_at: so.expires_at, + }; + + if existing.is_some() { + order_row.update_db(pool).await?; + return Ok(order_row); + } + + match order_row.insert_db(pool).await { + Ok(()) => Ok(order_row), + Err(e) => { + let is_unique_violation = match e.as_database_error() { + Some(db_err) => { + let code = db_err.code().map(|c| c.to_string()).unwrap_or_default(); + code == "1555" || code == "2067" + } + None => false, + }; + if is_unique_violation { + let ex = Self::get_by_id(pool, &id_str).await?; + let retry_so = small_order.clone(); + let updated = Order { + id: Some(id_str), + kind: retry_so.kind.as_ref().map(|k| k.to_string()), + status: retry_so.status.as_ref().map(|s| s.to_string()), + amount: retry_so.amount, + fiat_code: retry_so.fiat_code, + min_amount: retry_so.min_amount, + max_amount: retry_so.max_amount, + fiat_amount: retry_so.fiat_amount, + payment_method: retry_so.payment_method, + premium: retry_so.premium, + trade_keys: Some(trade_keys.secret_key().to_secret_hex()), + counterparty_pubkey: ex.counterparty_pubkey, + is_mine: ex.is_mine.or(Some(true)), + buyer_invoice: retry_so.buyer_invoice, + request_id: message_request_id.or(ex.request_id), + created_at: ex.created_at, + expires_at: retry_so.expires_at, + }; + updated.update_db(pool).await?; + Ok(updated) + } else { + Err(e.into()) + } + } + } + } + pub async fn get_by_id(pool: &SqlitePool, id: &str) -> Result { let order = sqlx::query_as::<_, Order>( r#" @@ -305,6 +408,28 @@ impl Order { Ok(order) } + + /// Update only the status field of an existing order by id. + /// The caller is responsible for providing a valid Mostro `Status`. + pub async fn update_status( + pool: &SqlitePool, + order_id: &str, + new_status: mostro_core::prelude::Status, + ) -> Result<()> { + sqlx::query( + r#" + UPDATE orders + SET status = ? + WHERE id = ? + "#, + ) + .bind(new_status.to_string()) + .bind(order_id) + .execute(pool) + .await?; + + Ok(()) + } } /// Admin dispute model for storing SolverDisputeInfo diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index ac13809..4fc6bba 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -138,6 +138,10 @@ pub struct AppState { /// Set when the user dismisses BackupNewKeys after runtime rotation. /// Main loop performs an in-process runtime reload and clears session state. pub pending_key_reload: bool, + /// When `take_order` completes while an AddInvoice/PayInvoice popup is open, we stash the + /// [`OperationResult`] here so the invoice UI is not replaced by the success screen (race). + /// Applied when the user dismisses the popup (Esc), or cleared when they submit the invoice. + pub pending_post_take_operation_result: Option, } impl AppState { @@ -177,6 +181,7 @@ impl AppState { mostro_info: None, backup_requires_restart: false, pending_key_reload: false, + pending_post_take_operation_result: None, } } diff --git a/src/ui/draw.rs b/src/ui/draw.rs index c57244a..88c6fbc 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -107,8 +107,12 @@ pub fn ui_draw( } // Confirmation popup overlay (user mode only) - if let UiMode::UserMode(UserMode::ConfirmingOrder(form)) = &app.mode { - order_confirm::render_order_confirm(f, form); + if let UiMode::UserMode(UserMode::ConfirmingOrder { + form, + selected_button, + }) = &app.mode + { + order_confirm::render_order_confirm(f, form, *selected_button); } // Waiting for Mostro popup overlay (user mode only) diff --git a/src/ui/exit_confirm.rs b/src/ui/exit_confirm.rs index 720d6a6..cf72248 100644 --- a/src/ui/exit_confirm.rs +++ b/src/ui/exit_confirm.rs @@ -1,4 +1,4 @@ -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; @@ -50,95 +50,8 @@ pub fn render_exit_confirm(f: &mut ratatui::Frame, selected_button: bool) { chunks[1], ); - // Yes/No buttons - let button_area = chunks[3]; - let button_width = 15; - let separator_width = 1; - let total_button_width = (button_width * 2) + separator_width; - - let button_x = button_area.x + (button_area.width.saturating_sub(total_button_width)) / 2; - let centered_button_area = Rect { - x: button_x, - y: button_area.y, - width: total_button_width.min(button_area.width), - height: button_area.height, - }; - - let button_chunks = Layout::new( - Direction::Horizontal, - [ - Constraint::Length(button_width), - Constraint::Length(separator_width), - Constraint::Length(button_width), - ], - ) - .split(centered_button_area); - - // YES button - let yes_style = if selected_button { - Style::default() - .bg(Color::Green) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - }; - - let yes_block = Block::default().borders(Borders::ALL).style(yes_style); - f.render_widget(yes_block, button_chunks[0]); - - let yes_inner = Layout::new(Direction::Vertical, [Constraint::Min(0)]) - .margin(1) - .split(button_chunks[0]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "✓ YES", - Style::default() - .fg(if selected_button { - Color::Black - } else { - Color::Green - }) - .add_modifier(Modifier::BOLD), - )])) - .alignment(ratatui::layout::Alignment::Center), - yes_inner[0], - ); - - // NO button - let no_style = if !selected_button { - Style::default() - .bg(Color::Red) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - }; - - let no_block = Block::default().borders(Borders::ALL).style(no_style); - f.render_widget(no_block, button_chunks[2]); - - let no_inner = Layout::new(Direction::Vertical, [Constraint::Min(0)]) - .margin(1) - .split(button_chunks[2]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "✗ NO", - Style::default() - .fg(if !selected_button { - Color::Black - } else { - Color::Red - }) - .add_modifier(Modifier::BOLD), - )])) - .alignment(ratatui::layout::Alignment::Center), - no_inner[0], - ); + // YES/NO buttons + helpers::render_yes_no_buttons(f, chunks[3], selected_button, "✓ YES", "✗ NO"); // Help text - first line f.render_widget( diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 28bb788..517e119 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1,10 +1,10 @@ use crate::models::AdminDispute; use chrono::DateTime; use mostro_core::prelude::UserInfo; -use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{ListItem, Paragraph}; +use ratatui::widgets::{Borders, ListItem, Paragraph}; use std::fs::{self, OpenOptions}; use std::io::Write; @@ -99,6 +99,108 @@ pub fn render_help_text(f: &mut ratatui::Frame, area: Rect, prefix: &str, key: & ); } +/// Render a pair of centered YES/NO buttons inside the given area. +/// `selected_button = true` highlights YES, `false` highlights NO. +pub fn render_yes_no_buttons( + f: &mut ratatui::Frame, + area: Rect, + selected_button: bool, + yes_label: &str, + no_label: &str, +) { + let button_width = 15; + let separator_width = 1; + let total_button_width = (button_width * 2) + separator_width; + + let button_x = area.x + (area.width.saturating_sub(total_button_width)) / 2; + let centered_button_area = Rect { + x: button_x, + y: area.y, + width: total_button_width.min(area.width), + height: area.height, + }; + + let button_chunks = Layout::new( + Direction::Horizontal, + [ + Constraint::Length(button_width), + Constraint::Length(separator_width), + Constraint::Length(button_width), + ], + ) + .split(centered_button_area); + + // YES button + let yes_style = if selected_button { + Style::default() + .bg(Color::Green) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + }; + + let yes_block = ratatui::widgets::Block::default() + .borders(Borders::ALL) + .style(yes_style); + f.render_widget(yes_block, button_chunks[0]); + + let yes_inner = Layout::new(Direction::Vertical, [Constraint::Min(0)]) + .margin(1) + .split(button_chunks[0]); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + yes_label, + Style::default() + .fg(if selected_button { + Color::Black + } else { + Color::Green + }) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center), + yes_inner[0], + ); + + // NO button + let no_style = if !selected_button { + Style::default() + .bg(Color::Red) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + }; + + let no_block = ratatui::widgets::Block::default() + .borders(Borders::ALL) + .style(no_style); + f.render_widget(no_block, button_chunks[2]); + + let no_inner = Layout::new(Direction::Vertical, [Constraint::Min(0)]) + .margin(1) + .split(button_chunks[2]); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + no_label, + Style::default() + .fg(if !selected_button { + Color::Black + } else { + Color::Red + }) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center), + no_inner[0], + ); +} + /// Formats an order ID for display (truncates to 8 chars) pub fn format_order_id(order_id: Option) -> String { if let Some(id) = order_id { diff --git a/src/ui/key_handler/async_tasks.rs b/src/ui/key_handler/async_tasks.rs index 476abd3..0255557 100644 --- a/src/ui/key_handler/async_tasks.rs +++ b/src/ui/key_handler/async_tasks.rs @@ -10,6 +10,7 @@ use crate::ui::{ use crate::util::fetch_mostro_instance_info; use crate::util::listen_for_order_messages; use crate::util::order_utils::spawn_fetch_scheduler_loops; +use crate::util::OrderDmSubscriptionCmd; use mostro_core::prelude::{Dispute, SmallOrder}; use nostr_sdk::prelude::{Client, Keys, PublicKey}; use sqlx::SqlitePool; @@ -34,6 +35,7 @@ fn clear_runtime_session_state(app: &mut AppState) { *pending = 0; } app.selected_message_idx = 0; + app.pending_post_take_operation_result = None; } /// Reload Nostr client, Mostro pubkey, and message listener after the user persisted new keys @@ -52,6 +54,7 @@ pub async fn apply_pending_key_reload( disputes: Arc>>, order_fetch_task: &mut JoinHandle<()>, dispute_fetch_task: &mut JoinHandle<()>, + dm_subscription_tx: &mut UnboundedSender, ) { match load_settings_from_disk() { Ok(latest_settings) => match latest_settings.nsec_privkey.parse::() { @@ -102,6 +105,9 @@ pub async fn apply_pending_key_reload( let messages_clone = Arc::clone(&app.messages); let message_notification_tx_clone = message_notification_tx.clone(); let pending_notifications_clone = Arc::clone(&app.pending_notifications); + let (new_dm_tx, new_dm_rx) = + tokio::sync::mpsc::unbounded_channel::(); + *dm_subscription_tx = new_dm_tx; *message_listener_handle = tokio::spawn(async move { listen_for_order_messages( client_for_messages, @@ -110,6 +116,7 @@ pub async fn apply_pending_key_reload( messages_clone, message_notification_tx_clone, pending_notifications_clone, + new_dm_rx, ) .await; }); @@ -160,6 +167,8 @@ pub struct AppChannels { pub save_attachment_rx: UnboundedReceiver<(String, ChatAttachment)>, pub mostro_info_tx: UnboundedSender, pub mostro_info_rx: UnboundedReceiver, + pub dm_subscription_tx: UnboundedSender, + pub dm_subscription_rx: UnboundedReceiver, } pub fn create_app_channels() -> AppChannels { @@ -177,6 +186,8 @@ pub fn create_app_channels() -> AppChannels { tokio::sync::mpsc::unbounded_channel::<(String, ChatAttachment)>(); let (mostro_info_tx, mostro_info_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (dm_subscription_tx, dm_subscription_rx) = + tokio::sync::mpsc::unbounded_channel::(); AppChannels { order_result_tx, @@ -193,6 +204,8 @@ pub fn create_app_channels() -> AppChannels { save_attachment_rx, mostro_info_tx, mostro_info_rx, + dm_subscription_tx, + dm_subscription_rx, } } @@ -200,6 +213,7 @@ pub fn spawn_send_new_order_task(ctx: &EnterKeyContext<'_>, form: FormState) { let pool = ctx.pool.clone(); let client = ctx.client.clone(); let order_result_tx = ctx.order_result_tx.clone(); + let dm_subscription_tx = ctx.dm_subscription_tx.clone(); let fallback_mostro_pubkey = ctx.mostro_pubkey; let current_mostro_pubkey = Arc::clone(ctx.current_mostro_pubkey); tokio::spawn(async move { @@ -212,7 +226,15 @@ pub fn spawn_send_new_order_task(ctx: &EnterKeyContext<'_>, form: FormState) { fallback_mostro_pubkey } }; - match crate::util::send_new_order(&pool, &client, mostro_pubkey, form).await { + match crate::util::send_new_order( + &pool, + &client, + mostro_pubkey, + form, + Some(&dm_subscription_tx), + ) + .await + { Ok(result) => { let _ = order_result_tx.send(result); } @@ -234,6 +256,7 @@ pub fn spawn_take_order_task( amount: Option, invoice: Option, result_tx: UnboundedSender, + dm_subscription_tx: UnboundedSender, ) { tokio::spawn(async move { match crate::util::take_order( @@ -244,6 +267,7 @@ pub fn spawn_take_order_task( &take_state.order, amount, invoice, + Some(&dm_subscription_tx), ) .await { diff --git a/src/ui/key_handler/confirmation.rs b/src/ui/key_handler/confirmation.rs index 2f81aeb..7bf1643 100644 --- a/src/ui/key_handler/confirmation.rs +++ b/src/ui/key_handler/confirmation.rs @@ -3,9 +3,7 @@ use crate::ui::{AdminMode, AppState, UiMode, UserMode, UserRole}; use crate::ui::key_handler::admin_handlers::{ execute_take_dispute_action, handle_enter_admin_mode, }; -use crate::ui::key_handler::async_tasks::{ - spawn_add_relay_task, spawn_refresh_mostro_info_task, spawn_send_new_order_task, -}; +use crate::ui::key_handler::async_tasks::{spawn_add_relay_task, spawn_refresh_mostro_info_task}; use crate::ui::key_handler::user_handlers::execute_take_order_action; use crate::ui::key_handler::settings::{ @@ -81,12 +79,14 @@ pub fn handle_confirm_key( UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), }; match std::mem::replace(&mut app.mode, default_mode.clone()) { - UiMode::UserMode(UserMode::ConfirmingOrder(form)) => { - // User confirmed, send the order - let form_clone = form.clone(); - app.mode = UiMode::UserMode(UserMode::WaitingForMostro(form_clone.clone())); - spawn_send_new_order_task(ctx, form_clone); - true + UiMode::UserMode(UserMode::ConfirmingOrder { form, .. }) => { + // ConfirmingOrder is now handled via Enter with YES/NO buttons. + // Keep mode unchanged; fallback returns false so the caller can decide. + app.mode = UiMode::UserMode(UserMode::ConfirmingOrder { + form, + selected_button: true, + }); + false } UiMode::UserMode(UserMode::TakingOrder(take_state)) => { // User confirmed taking the order (same as Enter key) @@ -97,6 +97,7 @@ pub fn handle_confirm_key( ctx.client, ctx.mostro_pubkey, ctx.order_result_tx, + ctx.dm_subscription_tx, ); true } @@ -232,7 +233,7 @@ pub fn handle_cancel_key(app: &mut AppState) { UserRole::User => UiMode::UserMode(UserMode::Normal), UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), }; - if let UiMode::UserMode(UserMode::ConfirmingOrder(form)) = &app.mode { + if let UiMode::UserMode(UserMode::ConfirmingOrder { form, .. }) = &app.mode { // User cancelled, go back to form app.mode = UiMode::UserMode(UserMode::CreatingOrder(form.clone())); } else if let UiMode::UserMode(UserMode::TakingOrder(_)) = &app.mode { diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 151cb6e..06de4bc 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -12,6 +12,7 @@ use crate::ui::{ use crate::ui::key_handler::async_tasks::{ spawn_key_rotation_task, spawn_load_seed_words_task, spawn_refresh_mostro_info_from_settings_task, spawn_refresh_mostro_info_task, + spawn_send_new_order_task, }; use crate::ui::key_handler::user_handlers::{ handle_enter_creating_order, handle_enter_taking_order, @@ -79,9 +80,19 @@ pub fn handle_enter_key(app: &mut AppState, ctx: &super::EnterKeyContext<'_>) -> handle_enter_creating_order(app, form); true } - UiMode::UserMode(UserMode::ConfirmingOrder(_)) => { - // Enter acts as Yes in confirmation - handled by 'y' key - app.mode = default_mode; + UiMode::UserMode(UserMode::ConfirmingOrder { + form, + selected_button, + }) => { + if selected_button { + // YES selected - send the order (similar to handle_confirm_key) + let form_clone = form.clone(); + app.mode = UiMode::UserMode(UserMode::WaitingForMostro(form_clone.clone())); + spawn_send_new_order_task(ctx, form_clone); + } else { + // NO selected - go back to form + app.mode = UiMode::UserMode(UserMode::CreatingOrder(form.clone())); + } true } UiMode::UserMode(UserMode::TakingOrder(take_state)) => { @@ -559,11 +570,15 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> focused: matches!(action, Action::AddInvoice), just_pasted: false, copied_to_clipboard: false, + scroll_y: 0, }; app.mode = UiMode::NewMessageNotification(notification, action, invoice_state); - } else { - // Show simple message view popup for other message types + } else if matches!( + action, + Action::HoldInvoicePaymentAccepted | Action::FiatSentOk + ) { + // Only these message types are actionable (send a follow-up message to Mostro). let notification = order_message_to_notification(msg); let view_state = MessageViewState { message_content: notification.message_preview, @@ -572,6 +587,11 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> selected_button: true, // Default to YES }; app.mode = UiMode::ViewingMessage(view_state); + } else { + // Non-actionable messages: show info popup (no "send" semantics). + let notification = order_message_to_notification(msg); + app.mode = + UiMode::OperationResult(OperationResult::Info(notification.message_preview)); } } } else if let Tab::Admin(AdminTab::Observer) = app.active_tab { diff --git a/src/ui/key_handler/esc_handlers.rs b/src/ui/key_handler/esc_handlers.rs index 302cab2..a5e962e 100644 --- a/src/ui/key_handler/esc_handlers.rs +++ b/src/ui/key_handler/esc_handlers.rs @@ -13,7 +13,7 @@ pub fn handle_esc_key(app: &mut AppState) -> bool { app.mode = default_mode.clone(); true } - UiMode::UserMode(UserMode::ConfirmingOrder(form)) => { + UiMode::UserMode(UserMode::ConfirmingOrder { form, .. }) => { // Cancel confirmation, go back to form app.mode = UiMode::UserMode(UserMode::CreatingOrder(form.clone())); true @@ -59,8 +59,12 @@ pub fn handle_esc_key(app: &mut AppState) -> bool { true } UiMode::NewMessageNotification(_, _, _) => { - // Dismiss notification - app.mode = UiMode::Normal; + // Dismiss notification; if take-order finished while this popup was open, show that result now. + app.mode = if let Some(r) = app.pending_post_take_operation_result.take() { + UiMode::OperationResult(r) + } else { + UiMode::Normal + }; true } UiMode::ViewingMessage(_) => { diff --git a/src/ui/key_handler/form_input.rs b/src/ui/key_handler/form_input.rs index 5ceeda2..7740426 100644 --- a/src/ui/key_handler/form_input.rs +++ b/src/ui/key_handler/form_input.rs @@ -1,3 +1,4 @@ +use crate::ui::orders::FormField; use crate::ui::{AppState, TakeOrderState, UiMode, UserMode}; use crossterm::event::KeyCode; @@ -10,14 +11,14 @@ pub fn handle_char_input( match code { KeyCode::Char(' ') => { if let UiMode::UserMode(UserMode::CreatingOrder(ref mut form)) = app.mode { - if form.focused == 0 { + if form.focused == FormField::OrderType { // Toggle buy/sell form.kind = if form.kind.to_lowercase() == "buy" { "sell".to_string() } else { "buy".to_string() }; - } else if form.focused == 3 { + } else if form.focused == FormField::FiatAmount { // Toggle range mode form.use_range = !form.use_range; } @@ -25,18 +26,18 @@ pub fn handle_char_input( } KeyCode::Char(c) => { if let UiMode::UserMode(UserMode::CreatingOrder(ref mut form)) = app.mode { - if form.focused == 0 { + if form.focused == FormField::OrderType { // ignore typing on toggle field } else { let target = match form.focused { - 1 => &mut form.fiat_code, - 2 => &mut form.amount, - 3 => &mut form.fiat_amount, - 4 if form.use_range => &mut form.fiat_amount_max, - 5 => &mut form.payment_method, - 6 => &mut form.premium, - 7 => &mut form.invoice, - 8 => &mut form.expiration_days, + FormField::Currency => &mut form.fiat_code, + FormField::AmountSats => &mut form.amount, + FormField::FiatAmount => &mut form.fiat_amount, + FormField::FiatAmountMax if form.use_range => &mut form.fiat_amount_max, + FormField::PaymentMethod => &mut form.payment_method, + FormField::Premium => &mut form.premium, + FormField::Invoice => &mut form.invoice, + FormField::ExpirationDays => &mut form.expiration_days, _ => unreachable!(), }; target.push(c); @@ -60,18 +61,18 @@ pub fn handle_char_input( /// Handle backspace for forms pub fn handle_backspace(app: &mut AppState, validate_range_amount: &dyn Fn(&mut TakeOrderState)) { if let UiMode::UserMode(UserMode::CreatingOrder(ref mut form)) = app.mode { - if form.focused == 0 { + if form.focused == FormField::OrderType { // ignore } else { let target = match form.focused { - 1 => &mut form.fiat_code, - 2 => &mut form.amount, - 3 => &mut form.fiat_amount, - 4 if form.use_range => &mut form.fiat_amount_max, - 5 => &mut form.payment_method, - 6 => &mut form.premium, - 7 => &mut form.invoice, - 8 => &mut form.expiration_days, + FormField::Currency => &mut form.fiat_code, + FormField::AmountSats => &mut form.amount, + FormField::FiatAmount => &mut form.fiat_amount, + FormField::FiatAmountMax if form.use_range => &mut form.fiat_amount_max, + FormField::PaymentMethod => &mut form.payment_method, + FormField::Premium => &mut form.premium, + FormField::Invoice => &mut form.invoice, + FormField::ExpirationDays => &mut form.expiration_days, _ => unreachable!(), }; target.pop(); diff --git a/src/ui/key_handler/message_handlers.rs b/src/ui/key_handler/message_handlers.rs index 5e58751..5926103 100644 --- a/src/ui/key_handler/message_handlers.rs +++ b/src/ui/key_handler/message_handlers.rs @@ -22,14 +22,9 @@ pub fn handle_enter_viewing_message( Action::HoldInvoicePaymentAccepted => Action::FiatSent, Action::FiatSentOk => Action::Release, _ => { - let _ = ctx.order_result_tx.send(OperationResult::Error( - "Invalid action for send message".to_string(), - )); - let default_mode = match app.user_role { - UserRole::User => UiMode::UserMode(UserMode::Normal), - UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), - }; - app.mode = default_mode; + // This view is sometimes used as a generic "view message" popup; if the message + // doesn't map to a sendable action, just dismiss without error. + app.mode = UiMode::Normal; return; } }; @@ -102,6 +97,7 @@ pub fn handle_enter_message_notification( UserRole::User => UiMode::UserMode(UserMode::WaitingAddInvoice), UserRole::Admin => UiMode::AdminMode(AdminMode::Normal), }; + app.pending_post_take_operation_result = None; app.mode = default_mode; // Send invoice to Mostro diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 07cca64..6f5d7a0 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -17,6 +17,7 @@ use crate::ui::{ AdminMode, AdminTab, AppState, ChatAttachment, ChatSender, DisputeFilter, MostroInfoFetchResult, OperationResult, Tab, TakeOrderState, UiMode, UserMode, UserTab, }; +use crate::util::OrderDmSubscriptionCmd; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use mostro_core::prelude::*; use nostr_sdk::prelude::*; @@ -39,6 +40,7 @@ pub struct EnterKeyContext<'a> { pub seed_words_tx: &'a UnboundedSender, String>>, pub mostro_info_tx: &'a UnboundedSender, pub admin_chat_keys: Option<&'a Keys>, + pub dm_subscription_tx: &'a UnboundedSender, } // Re-export public functions @@ -99,32 +101,75 @@ fn handle_admin_chat_input( /// Handle clipboard copy for invoice fn handle_clipboard_copy(invoice: String) -> bool { - let copy_result = { - match arboard::Clipboard::new() { - Ok(mut clipboard) => { - #[cfg(target_os = "linux")] + #[cfg(target_os = "linux")] + { + // On Linux, prefer arboard (system clipboard) but run it off the UI thread. + // Some clipboard backends can emit warnings to stderr; silence stderr during the call + // to avoid corrupting the TUI. + std::thread::spawn(move || { + let copy_result = { + #[cfg(unix)] { - use arboard::SetExtLinux; - clipboard.set().wait().text(invoice) + use std::os::unix::io::AsRawFd; + let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) }; + let devnull = std::fs::File::open("/dev/null"); + if saved_stderr >= 0 { + if let Ok(devnull) = devnull { + unsafe { + let _ = libc::dup2(devnull.as_raw_fd(), libc::STDERR_FILENO); + } + } + } + + let r = match arboard::Clipboard::new() { + Ok(mut clipboard) => clipboard.set_text(invoice), + Err(e) => Err(e), + }; + + if saved_stderr >= 0 { + unsafe { + let _ = libc::dup2(saved_stderr, libc::STDERR_FILENO); + let _ = libc::close(saved_stderr); + } + } + r } - #[cfg(not(target_os = "linux"))] + #[cfg(not(unix))] { - clipboard.set_text(invoice) + match arboard::Clipboard::new() { + Ok(mut clipboard) => clipboard.set_text(invoice), + Err(e) => Err(e), + } } + }; + + match copy_result { + Ok(_) => log::info!("Invoice copied to clipboard"), + Err(e) => log::warn!("Failed to copy invoice to clipboard: {}", e), } - Err(e) => Err(e), - } - }; + }); + true + } - match copy_result { - Ok(_) => { - log::info!("Invoice copied to clipboard"); - true - } - Err(e) => { - log::warn!("Failed to copy invoice to clipboard: {}", e); - false - } + // Non-Linux: clipboard ops can still block; run off UI thread. + #[cfg(not(target_os = "linux"))] + { + std::thread::spawn(move || { + let copy_result = match arboard::Clipboard::new() { + Ok(mut clipboard) => clipboard.set_text(invoice), + Err(e) => Err(e), + }; + + match copy_result { + Ok(_) => { + log::info!("Invoice copied to clipboard"); + } + Err(e) => { + log::warn!("Failed to copy invoice to clipboard: {}", e); + } + } + }); + true } } @@ -170,6 +215,7 @@ pub fn handle_key_event( validate_range_amount: &dyn Fn(&mut TakeOrderState), admin_chat_keys: Option<&nostr_sdk::Keys>, save_attachment_tx: Option<&UnboundedSender<(String, ChatAttachment)>>, + dm_subscription_tx: &UnboundedSender, ) -> Option { // Returns Some(true) to continue, Some(false) to break, None to continue normally let code = key_event.code; @@ -189,6 +235,29 @@ pub fn handle_key_event( return Some(true); // consume all other keys while help is open } + // PayInvoice popup: allow scrolling the (wrapped) invoice text. + if let UiMode::NewMessageNotification(_, Action::PayInvoice, ref mut invoice_state) = app.mode { + match code { + KeyCode::Up => { + invoice_state.scroll_y = invoice_state.scroll_y.saturating_sub(1); + return Some(true); + } + KeyCode::Down => { + invoice_state.scroll_y = invoice_state.scroll_y.saturating_add(1); + return Some(true); + } + KeyCode::PageUp => { + invoice_state.scroll_y = invoice_state.scroll_y.saturating_sub(10); + return Some(true); + } + KeyCode::PageDown => { + invoice_state.scroll_y = invoice_state.scroll_y.saturating_add(10); + return Some(true); + } + _ => {} + } + } + // Save attachment popup: Up/Down to select, Enter to save, Esc to cancel if matches!(app.mode, UiMode::SaveAttachmentPopup(_)) { let dispute_id_key = app @@ -611,6 +680,7 @@ pub fn handle_key_event( seed_words_tx, mostro_info_tx, admin_chat_keys, + dm_subscription_tx, }; let should_continue = handle_enter_key(app, &ctx); Some(should_continue) @@ -636,24 +706,8 @@ pub fn handle_key_event( } Some(true) } - // 'q' key removed - use Exit tab instead - KeyCode::Char('y') | KeyCode::Char('Y') => { - let ctx = EnterKeyContext { - orders, - disputes, - pool, - client, - mostro_pubkey, - current_mostro_pubkey, - order_result_tx, - key_rotation_tx, - seed_words_tx, - mostro_info_tx, - admin_chat_keys, - }; - let should_continue = handle_confirm_key(app, &ctx); - Some(should_continue) - } + // 'q' key removed - use Exit tab instead. + // For confirmations, prefer using Enter on the focused button instead of 'y'/'n'. KeyCode::Char('n') | KeyCode::Char('N') => { handle_cancel_key(app); Some(true) diff --git a/src/ui/key_handler/navigation.rs b/src/ui/key_handler/navigation.rs index fbc97a8..e54f21a 100644 --- a/src/ui/key_handler/navigation.rs +++ b/src/ui/key_handler/navigation.rs @@ -33,6 +33,14 @@ fn handle_left_key(app: &mut AppState, _orders: &Arc>>) { // Leave form mode app.mode = UiMode::UserMode(UserMode::Normal); } + // In order confirmation popup, Left should only move the selection to YES, + // not switch tabs. + UiMode::UserMode(UserMode::ConfirmingOrder { + ref mut selected_button, + .. + }) => { + *selected_button = true; + } UiMode::Normal | UiMode::UserMode(UserMode::Normal) | UiMode::AdminMode(AdminMode::Normal) @@ -40,6 +48,11 @@ fn handle_left_key(app: &mut AppState, _orders: &Arc>>) { let prev_tab = app.active_tab; app.active_tab = app.active_tab.prev(app.user_role); handle_tab_switch(app, prev_tab); + // Auto-initialize form when switching to Create New Order tab (user mode only) + if let Tab::User(UserTab::CreateNewOrder) = app.active_tab { + let form = FormState::new_default_form(); + app.mode = UiMode::UserMode(UserMode::CreatingOrder(form)); + } } UiMode::UserMode(UserMode::TakingOrder(ref mut take_state)) => { // Switch to YES button (left side) @@ -82,6 +95,14 @@ fn handle_right_key(app: &mut AppState, _orders: &Arc>>) { // Leave form mode app.mode = UiMode::UserMode(UserMode::Normal); } + // In order confirmation popup, Right should only move the selection to NO, + // not switch tabs. + UiMode::UserMode(UserMode::ConfirmingOrder { + ref mut selected_button, + .. + }) => { + *selected_button = false; + } UiMode::Normal | UiMode::UserMode(UserMode::Normal) | UiMode::AdminMode(AdminMode::Normal) @@ -91,15 +112,7 @@ fn handle_right_key(app: &mut AppState, _orders: &Arc>>) { handle_tab_switch(app, prev_tab); // Auto-initialize form when switching to Create New Order tab (user mode only) if let Tab::User(UserTab::CreateNewOrder) = app.active_tab { - let form = FormState { - kind: "buy".to_string(), - fiat_code: "USD".to_string(), - amount: "0".to_string(), - premium: "0".to_string(), - expiration_days: "1".to_string(), - focused: 1, - ..Default::default() - }; + let form = FormState::new_default_form(); app.mode = UiMode::UserMode(UserMode::CreatingOrder(form)); } } @@ -197,15 +210,9 @@ fn handle_up_key( } } UiMode::UserMode(UserMode::CreatingOrder(form)) => { - if form.focused > 0 { - form.focused -= 1; - // Skip field 4 if not using range (go from 5 to 3) - if form.focused == 4 && !form.use_range { - form.focused = 3; - } - } + form.focused = form.focused.prev(form.use_range); } - UiMode::UserMode(UserMode::ConfirmingOrder(_)) + UiMode::UserMode(UserMode::ConfirmingOrder { .. }) | UiMode::UserMode(UserMode::TakingOrder(_)) | UiMode::UserMode(UserMode::WaitingForMostro(_)) | UiMode::UserMode(UserMode::WaitingTakeOrder(_)) @@ -314,13 +321,7 @@ fn handle_down_key( } } UiMode::UserMode(UserMode::CreatingOrder(form)) => { - if form.focused < 8 { - form.focused += 1; - // Skip field 4 if not using range (go from 3 to 5) - if form.focused == 4 && !form.use_range { - form.focused = 5; - } - } + form.focused = form.focused.next(form.use_range); } UiMode::AdminMode(AdminMode::ManagingDispute) => { // Navigate within disputes in progress list @@ -333,7 +334,7 @@ fn handle_down_key( } } } - UiMode::UserMode(UserMode::ConfirmingOrder(_)) + UiMode::UserMode(UserMode::ConfirmingOrder { .. }) | UiMode::UserMode(UserMode::TakingOrder(_)) | UiMode::UserMode(UserMode::WaitingForMostro(_)) | UiMode::UserMode(UserMode::WaitingTakeOrder(_)) @@ -415,11 +416,7 @@ pub fn handle_tab_navigation(code: KeyCode, app: &mut AppState) { // Reset scroll/selection when switching parties (will be set in render) app.admin_chat_selected_message_idx = None; } else if let UiMode::UserMode(UserMode::CreatingOrder(ref mut form)) = app.mode { - form.focused = (form.focused + 1) % 9; - // Skip field 4 if not using range - if form.focused == 4 && !form.use_range { - form.focused = 5; - } + form.focused = form.focused.next(form.use_range); } } KeyCode::BackTab => { @@ -431,15 +428,7 @@ pub fn handle_tab_navigation(code: KeyCode, app: &mut AppState) { // Reset scroll/selection when switching parties (will be set in render) app.admin_chat_selected_message_idx = None; } else if let UiMode::UserMode(UserMode::CreatingOrder(ref mut form)) = app.mode { - form.focused = if form.focused == 0 { - 8 - } else { - form.focused - 1 - }; - // Skip field 4 if not using range - if form.focused == 4 && !form.use_range { - form.focused = 3; - } + form.focused = form.focused.prev(form.use_range); } } _ => {} diff --git a/src/ui/key_handler/user_handlers.rs b/src/ui/key_handler/user_handlers.rs index 088d6d5..c37b5d5 100644 --- a/src/ui/key_handler/user_handlers.rs +++ b/src/ui/key_handler/user_handlers.rs @@ -8,7 +8,10 @@ use tokio::sync::mpsc::UnboundedSender; pub fn handle_enter_creating_order(app: &mut AppState, form: &FormState) { // Show confirmation popup when Enter is pressed if let Tab::User(UserTab::CreateNewOrder) = app.active_tab { - app.mode = UiMode::UserMode(UserMode::ConfirmingOrder(form.clone())); + app.mode = UiMode::UserMode(UserMode::ConfirmingOrder { + form: form.clone(), + selected_button: true, // default to YES + }); } else { app.mode = UiMode::UserMode(UserMode::CreatingOrder(form.clone())); } @@ -30,6 +33,7 @@ pub fn handle_enter_taking_order( ctx.client, ctx.mostro_pubkey, ctx.order_result_tx, + ctx.dm_subscription_tx, ); } else { // NO selected - cancel and return to the appropriate normal mode @@ -52,6 +56,7 @@ pub(crate) fn execute_take_order_action( client: &Client, mostro_pubkey: nostr_sdk::PublicKey, order_result_tx: &UnboundedSender, + dm_subscription_tx: &UnboundedSender, ) -> bool { // Validate range order if needed if take_state.is_range_order { @@ -101,6 +106,7 @@ pub(crate) fn execute_take_order_action( amount, invoice, order_result_tx.clone(), + dm_subscription_tx.clone(), ); true diff --git a/src/ui/message_notification.rs b/src/ui/message_notification.rs index dc52fc4..6e10588 100644 --- a/src/ui/message_notification.rs +++ b/src/ui/message_notification.rs @@ -83,7 +83,12 @@ fn render_invoice_input(f: &mut ratatui::Frame, area: Rect, invoice_state: &Invo } /// Renders the invoice display field for PayInvoice -fn render_invoice_display(f: &mut ratatui::Frame, area: Rect, invoice: Option<&String>) { +fn render_invoice_display( + f: &mut ratatui::Frame, + area: Rect, + invoice: Option<&String>, + scroll_y: u16, +) { let (invoice_text, text_color) = match invoice { Some(inv) if !inv.is_empty() => (inv.clone(), Color::White), Some(_) => ( @@ -94,16 +99,15 @@ fn render_invoice_display(f: &mut ratatui::Frame, area: Rect, invoice: Option<&S }; f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - invoice_text, - Style::default().fg(text_color).add_modifier(Modifier::BOLD), - )])) - .wrap(ratatui::widgets::Wrap { trim: true }) - .block( - Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(PRIMARY_COLOR)), - ), + Paragraph::new(invoice_text) + .style(Style::default().fg(text_color).add_modifier(Modifier::BOLD)) + .wrap(ratatui::widgets::Wrap { trim: true }) + .scroll((scroll_y, 0)) + .block( + Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(PRIMARY_COLOR)), + ), area, ); } @@ -223,7 +227,12 @@ fn render_pay_invoice( chunks[4], ); - render_invoice_display(f, chunks[5], notification.invoice.as_ref()); + render_invoice_display( + f, + chunks[5], + notification.invoice.as_ref(), + invoice_state.scroll_y, + ); // Help text - first line if invoice_state.copied_to_clipboard { @@ -247,7 +256,14 @@ fn render_pay_invoice( .fg(PRIMARY_COLOR) .add_modifier(Modifier::BOLD), ), - Span::styled(" to copy invoice to clipboard, or ", Style::default()), + Span::styled(" to copy invoice to clipboard. ", Style::default()), + Span::styled( + "↑/↓", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" scroll, ", Style::default()), Span::styled( "Shift", Style::default() diff --git a/src/ui/operation_result.rs b/src/ui/operation_result.rs index 1e010df..a4c8e2d 100644 --- a/src/ui/operation_result.rs +++ b/src/ui/operation_result.rs @@ -1,4 +1,4 @@ -use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; @@ -120,8 +120,20 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) Style::default().fg(Color::DarkGray), )])); + let content_height: u16 = lines.len().try_into().unwrap_or(inner.height); let paragraph = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); - f.render_widget(paragraph, inner); + let vertical_chunks = Layout::new( + Direction::Vertical, + [ + Constraint::Min(0), + Constraint::Length(content_height.min(inner.height)), + Constraint::Min(0), + ], + ) + .split(inner); + let content_area = vertical_chunks[1]; + + f.render_widget(paragraph, content_area); } OperationResult::Error(error_msg) => { let block = Block::default() @@ -186,7 +198,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) f.render_widget(paragraph, inner); } OperationResult::ObserverChatLoaded(_) | OperationResult::ObserverChatError(_) => { - // Handled directly in handle_order_result, should not reach render + // Handled directly in handle_operation_result, should not reach render } OperationResult::PaymentRequestRequired { .. } => { // This should not be displayed - it's converted to a notification in main.rs diff --git a/src/ui/order_confirm.rs b/src/ui/order_confirm.rs index b8817cd..90fb551 100644 --- a/src/ui/order_confirm.rs +++ b/src/ui/order_confirm.rs @@ -1,11 +1,11 @@ use ratatui::layout::{Constraint, Direction, Flex, Layout}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; -use super::{FormState, BACKGROUND_COLOR, PRIMARY_COLOR}; +use super::{helpers, FormState, BACKGROUND_COLOR, PRIMARY_COLOR}; -pub fn render_order_confirm(f: &mut ratatui::Frame, form: &FormState) { +pub fn render_order_confirm(f: &mut ratatui::Frame, form: &FormState, selected_button: bool) { let area = f.area(); let popup_width = area.width.saturating_sub(area.width / 4); let popup_height = 20; @@ -37,8 +37,8 @@ pub fn render_order_confirm(f: &mut ratatui::Frame, form: &FormState) { Constraint::Length(1), // premium Constraint::Length(1), // invoice (if present) Constraint::Length(1), // expiration - Constraint::Length(1), // separator - Constraint::Length(1), // confirmation prompt + Constraint::Length(3), // buttons + Constraint::Length(1), // help text ], ) .split(popup); @@ -160,24 +160,15 @@ pub fn render_order_confirm(f: &mut ratatui::Frame, form: &FormState) { inner_chunks[10], ); - // Confirmation prompt - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Press ", Style::default()), - Span::styled( - "Y", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" to confirm or "), - Span::styled( - "N", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - Span::raw(" to cancel"), - ])) - .alignment(ratatui::layout::Alignment::Center), + // YES/NO buttons + helpers::render_yes_no_buttons(f, inner_chunks[11], selected_button, "✓ YES", "✗ NO"); + + // Help text: use Enter/Esc for confirmation + helpers::render_help_text( + f, inner_chunks[12], + "Press ", + "Enter", + " to confirm, Esc to cancel", ); } diff --git a/src/ui/order_form.rs b/src/ui/order_form.rs index b384d71..b411bc0 100644 --- a/src/ui/order_form.rs +++ b/src/ui/order_form.rs @@ -4,23 +4,46 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use super::{FormState, BACKGROUND_COLOR, PRIMARY_COLOR}; +use crate::ui::orders::FormField; pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { // Calculate number of fields dynamically let field_count = if form.use_range { 10 } else { 9 }; - let mut constraints = vec![Constraint::Length(1)]; // spacer + // Start with a top spacer so the form doesn't hug the frame border + let mut constraints = vec![Constraint::Length(2)]; // spacer for _ in 0..field_count { - constraints.push(Constraint::Length(3)); + // Give each field a bit more vertical space to improve readability + constraints.push(Constraint::Length(4)); } - constraints.push(Constraint::Length(1)); // hint - - let inner_chunks = Layout::new(Direction::Vertical, constraints).split(area); + // Slightly taller row for the footer hint + constraints.push(Constraint::Length(2)); // hint + // Outer frame for the whole tab let block = Block::default() .title("✨ Create New Order") .borders(Borders::ALL) .style(Style::default().bg(BACKGROUND_COLOR).fg(PRIMARY_COLOR)); - f.render_widget(block, area); + f.render_widget(&block, area); + + // Work inside the inner area of the frame + let inner = block.inner(area); + + // Horizontal layout: spacer | centered form | help panel + let h_chunks = Layout::new( + Direction::Horizontal, + [ + Constraint::Percentage(10), + Constraint::Min(40), + Constraint::Percentage(30), + ], + ) + .split(inner); + + let form_area = h_chunks[1]; + let help_area = h_chunks[2]; + + // Vertical layout for the form fields inside the centered column + let inner_chunks = Layout::new(Direction::Vertical, constraints).split(form_area); let mut field_idx = 1; @@ -31,7 +54,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { Span::styled("Order Type", Style::default().add_modifier(Modifier::BOLD)), ])) .borders(Borders::ALL) - .style(if form.focused == 0 { + .style(if form.focused == FormField::OrderType { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -64,7 +87,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { Span::styled("Currency", Style::default().add_modifier(Modifier::BOLD)), ])) .borders(Borders::ALL) - .style(if form.focused == 1 { + .style(if form.focused == FormField::Currency { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -84,7 +107,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { ), ])) .borders(Borders::ALL) - .style(if form.focused == 2 { + .style(if form.focused == FormField::AmountSats { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -101,7 +124,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { Span::styled(" (Space to toggle)", Style::default().fg(Color::DarkGray)), ])) .borders(Borders::ALL) - .style(if form.focused == 3 { + .style(if form.focused == FormField::FiatAmount { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -147,7 +170,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { ), ])) .borders(Borders::ALL) - .style(if form.focused == 4 { + .style(if form.focused == FormField::FiatAmountMax { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -168,7 +191,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { ), ])) .borders(Borders::ALL) - .style(if form.focused == 5 { + .style(if form.focused == FormField::PaymentMethod { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -185,7 +208,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { Span::styled("Premium (%)", Style::default().add_modifier(Modifier::BOLD)), ])) .borders(Borders::ALL) - .style(if form.focused == 6 { + .style(if form.focused == FormField::Premium { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -205,7 +228,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { ), ])) .borders(Borders::ALL) - .style(if form.focused == 7 { + .style(if form.focused == FormField::Invoice { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -225,7 +248,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { ), ])) .borders(Borders::ALL) - .style(if form.focused == 8 { + .style(if form.focused == FormField::ExpirationDays { Style::default().fg(Color::Black).bg(PRIMARY_COLOR) } else { Style::default().bg(BACKGROUND_COLOR).fg(Color::White) @@ -234,7 +257,7 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { f.render_widget(exp, inner_chunks[field_idx]); field_idx += 1; - // Footer hint + // Footer hint (still in the form column) let hint = Paragraph::new(Line::from(vec![ Span::styled("💡 ", Style::default().fg(Color::Cyan)), Span::styled( @@ -267,28 +290,42 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { .block(Block::default()); f.render_widget(hint, inner_chunks[field_idx]); + // Contextual help panel on the right side + let help_lines = build_field_help(form); + let help_paragraph = Paragraph::new(help_lines) + .block( + Block::default() + .title("Field Help") + .borders(Borders::ALL) + .style(Style::default().bg(BACKGROUND_COLOR)), + ) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(help_paragraph, help_area); + // Show cursor in active text field let cursor_field = match form.focused { - 1 => Some((inner_chunks[2], &form.fiat_code, 0)), - 2 => Some((inner_chunks[3], &form.amount, 0)), - 3 => Some((inner_chunks[4], &form.fiat_amount, 11)), // 11 chars for "[ Single ] " or "[ Range ] " - 4 if form.use_range => Some((inner_chunks[5], &form.fiat_amount_max, 0)), - 5 => Some(( + FormField::Currency => Some((inner_chunks[2], &form.fiat_code, 0)), + FormField::AmountSats => Some((inner_chunks[3], &form.amount, 0)), + FormField::FiatAmount => Some((inner_chunks[4], &form.fiat_amount, 11)), // 11 chars for "[ Single ] " or "[ Range ] " + FormField::FiatAmountMax if form.use_range => { + Some((inner_chunks[5], &form.fiat_amount_max, 0)) + } + FormField::PaymentMethod => Some(( inner_chunks[if form.use_range { 6 } else { 5 }], &form.payment_method, 0, )), - 6 => Some(( + FormField::Premium => Some(( inner_chunks[if form.use_range { 7 } else { 6 }], &form.premium, 0, )), - 7 => Some(( + FormField::Invoice => Some(( inner_chunks[if form.use_range { 8 } else { 7 }], &form.invoice, 0, )), - 8 => Some(( + FormField::ExpirationDays => Some(( inner_chunks[if form.use_range { 9 } else { 8 }], &form.expiration_days, 0, @@ -302,6 +339,60 @@ pub fn render_order_form(f: &mut ratatui::Frame, area: Rect, form: &FormState) { } } +fn build_field_help(form: &FormState) -> Vec> { + match form.focused { + FormField::OrderType => vec![ + Line::from("Order Type"), + Line::from("Choose whether you want to buy or sell bitcoin."), + Line::from("Use Space to toggle between buy and sell orders."), + ], + FormField::Currency => vec![ + Line::from("Currency"), + Line::from("Enter the fiat currency code (e.g. USD, EUR)."), + Line::from("It must be one of the currencies accepted by the Mostro instance."), + ], + FormField::AmountSats => vec![ + Line::from("Amount (sats)"), + Line::from("Amount in satoshis you want to trade."), + Line::from("Set to 0 to create a market order, so the order will be executed at the current market price."), + ], + FormField::FiatAmount => vec![ + Line::from("Fiat Amount"), + Line::from("Price of the order in fiat currency (e.g. USD, EUR, ARS, etc.)."), + Line::from("Use Space to toggle between a single amount and a range (e.g. 100-200 USD)."), + ], + FormField::FiatAmountMax if form.use_range => vec![ + Line::from("Fiat Amount (Max)"), + Line::from("Upper bound of the fiat amount range."), + Line::from("Leave narrow if you only need a rough upper limit."), + ], + FormField::PaymentMethod => vec![ + Line::from("Payment Method"), + Line::from("Describe how you want to receive or send fiat."), + Line::from("Use a short but recognizable label (e.g. SEPA, Bizum)."), + ], + FormField::Premium => vec![ + Line::from("Premium (%)"), + Line::from("Markup or discount relative to the reference price."), + Line::from("Positive values are a premium, negative values a discount."), + ], + FormField::Invoice => vec![ + Line::from("Invoice (optional)"), + Line::from("Pre-generated Lightning invoice, if applicable."), + Line::from("You can leave this empty for Mostro to handle invoices."), + ], + FormField::ExpirationDays => vec![ + Line::from("Expiration (days)"), + Line::from("How long the order should remain active."), + Line::from("Use 0 for no expiration."), + ], + _ => vec![ + Line::from("Create New Order"), + Line::from("Fill the fields on the left and press Enter to submit."), + ], + } +} + pub fn render_form_initializing(f: &mut ratatui::Frame, area: Rect) { let paragraph = Paragraph::new(Span::raw("Initializing form...")).block( Block::default() diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 82aefa2..00530c6 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -46,6 +46,64 @@ pub enum MostroInfoFetchResult { Err(String), } +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)] +pub enum FormField { + #[default] + OrderType, + Currency, + AmountSats, + FiatAmount, + FiatAmountMax, + PaymentMethod, + Premium, + Invoice, + ExpirationDays, +} + +impl FormField { + pub fn next(self, use_range: bool) -> Self { + use FormField::*; + match self { + OrderType => Currency, + Currency => AmountSats, + AmountSats => FiatAmount, + FiatAmount => { + if use_range { + FiatAmountMax + } else { + PaymentMethod + } + } + FiatAmountMax => PaymentMethod, + PaymentMethod => Premium, + Premium => Invoice, + Invoice => ExpirationDays, + ExpirationDays => OrderType, + } + } + + pub fn prev(self, use_range: bool) -> Self { + use FormField::*; + match self { + OrderType => ExpirationDays, + Currency => OrderType, + AmountSats => Currency, + FiatAmount => AmountSats, + FiatAmountMax => FiatAmount, + PaymentMethod => { + if use_range { + FiatAmountMax + } else { + FiatAmount + } + } + Premium => PaymentMethod, + Invoice => Premium, + ExpirationDays => Invoice, + } + } +} + #[derive(Clone, Debug, Default)] pub struct FormState { pub kind: String, // buy | sell @@ -57,10 +115,25 @@ pub struct FormState { pub premium: String, // premium percentage pub invoice: String, // optional invoice pub expiration_days: String, // expiration days (0 for no expiration) - pub focused: usize, // field index + pub focused: FormField, // which field is focused pub use_range: bool, // whether to use fiat range } +impl FormState { + /// Create a default order form used when entering the Create New Order tab. + pub fn new_default_form() -> Self { + Self { + kind: "buy".to_string(), + fiat_code: "USD".to_string(), + amount: "0".to_string(), + premium: "0".to_string(), + expiration_days: "1".to_string(), + focused: FormField::Currency, + ..Default::default() + } + } +} + #[derive(Clone, Debug)] pub struct TakeOrderState { pub order: SmallOrder, @@ -103,6 +176,8 @@ pub struct InvoiceInputState { pub focused: bool, pub just_pasted: bool, // Flag to ignore Enter immediately after paste pub copied_to_clipboard: bool, // Flag to show "Copied!" message + /// Vertical scroll offset for long invoice display (PayInvoice popup). + pub scroll_y: u16, } /// State for handling key input (pubkey or privkey) in admin settings diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index d9c3e8f..3cc075b 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -115,9 +115,14 @@ pub fn render_orders_tab( let payment_method_cell = Cell::from(order.payment_method.clone()); let date = DateTime::from_timestamp(order.created_at.unwrap_or(0), 0); + // Convert UTC timestamp to local time for display let date_cell = Cell::from( - date.map(|d| d.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "Invalid date".to_string()), + date.map(|d| { + d.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) + .unwrap_or_else(|| "Invalid date".to_string()), ); let row = Row::new(vec![ diff --git a/src/ui/user_state.rs b/src/ui/user_state.rs index 84d2e6c..5550db6 100644 --- a/src/ui/user_state.rs +++ b/src/ui/user_state.rs @@ -4,7 +4,11 @@ use crate::ui::{FormState, TakeOrderState}; pub enum UserMode { Normal, CreatingOrder(FormState), - ConfirmingOrder(FormState), // Confirmation popup + ConfirmingOrder { + // Confirmation popup with YES/NO selection + form: FormState, + selected_button: bool, // true = YES, false = NO + }, TakingOrder(TakeOrderState), // Taking an order from the list WaitingForMostro(FormState), // Waiting for Mostro response (order creation) WaitingTakeOrder(TakeOrderState), // Waiting for Mostro response (taking order) diff --git a/src/util/db_utils.rs b/src/util/db_utils.rs index 7859c71..f264d3f 100644 --- a/src/util/db_utils.rs +++ b/src/util/db_utils.rs @@ -5,15 +5,19 @@ use sqlx::sqlite::SqlitePool; use crate::models::{Order, User}; -/// Save an order to the database (ported from mostro-cli) +/// Save an order to the database (ported from mostro-cli). +/// +/// `is_maker`: `true` when the user published the order (maker), `false` when they took an order (taker). pub async fn save_order( order: SmallOrder, trade_keys: &Keys, request_id: u64, trade_index: i64, pool: &SqlitePool, + is_maker: bool, ) -> Result<()> { - if let Ok(order) = Order::new(pool, order, trade_keys, Some(request_id as i64)).await { + if let Ok(order) = Order::new(pool, order, trade_keys, Some(request_id as i64), is_maker).await + { if let Some(order_id) = order.id { log::info!("Order {} created", order_id); } else { @@ -31,3 +35,37 @@ pub async fn save_order( } Ok(()) } + +/// Update the status for an existing order in the local database. +/// This is a thin wrapper over `Order::update_status` that logs failures. +pub async fn update_order_status(pool: &SqlitePool, order_id: &str, status: Status) -> Result<()> { + match Order::update_status(pool, order_id, status).await { + Ok(()) => { + log::info!("Updated status for order {} to {:?}", order_id, status); + Ok(()) + } + Err(e) => { + log::error!( + "Failed to update status for order {} to {:?}: {}", + order_id, + status, + e + ); + Err(e) + } + } +} + +/// Best-effort helper to sync the local DB status from a `SmallOrder` that was +/// fetched from relays (e.g. via `order_from_tags`), when an order row already +/// exists locally. +pub async fn refresh_order_status_from_small_order( + pool: &SqlitePool, + small_order: &SmallOrder, +) -> Result<()> { + if let (Some(order_id), Some(status)) = (small_order.id, small_order.status) { + // Ignore errors here; callers typically run this as a background refresh. + let _ = update_order_status(pool, &order_id.to_string(), status).await; + } + Ok(()) +} diff --git a/src/util/dm_utils/mod.rs b/src/util/dm_utils/mod.rs index 88be81e..fdb6d15 100644 --- a/src/util/dm_utils/mod.rs +++ b/src/util/dm_utils/mod.rs @@ -6,7 +6,7 @@ mod notifications_ch_mng; mod order_ch_mng; pub use notifications_ch_mng::handle_message_notification; -pub use order_ch_mng::handle_order_result; +pub use order_ch_mng::handle_operation_result; use anyhow::Result; use mostro_core::prelude::*; @@ -15,13 +15,59 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::{Arc, Mutex}; -use crate::models::User; +use crate::models::{Order, User}; use crate::ui::{MessageNotification, OrderMessage}; +use crate::util::db_utils::update_order_status; +use crate::util::order_utils::{inferred_status_from_trade_action, map_action_to_status}; use crate::util::types::{determine_message_type, MessageType}; use crate::SETTINGS; pub const FETCH_EVENTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +#[derive(Debug, Clone)] +pub enum OrderDmSubscriptionCmd { + Subscribe { + order_id: uuid::Uuid, + trade_index: i64, + }, +} + +fn is_terminal_order_status(status: Status) -> bool { + matches!( + status, + Status::Success + | Status::Canceled + | Status::CanceledByAdmin + | Status::SettledByAdmin + | Status::CompletedByAdmin + | Status::Expired + | Status::CooperativelyCanceled + ) +} + +fn message_has_terminal_order_status(message: &Message) -> bool { + message + .get_inner_message_kind() + .payload + .as_ref() + .and_then(|payload| match payload { + Payload::Order(order) => order.status, + _ => None, + }) + .map(is_terminal_order_status) + .unwrap_or(false) +} + +/// Terminal end of trade: either `SmallOrder.status` in the payload, or actions that +/// Mostro sends with `payload: null` (e.g. `canceled`). +fn trade_message_is_terminal(message: &Message) -> bool { + let kind = message.get_inner_message_kind(); + if matches!(&kind.action, Action::Canceled | Action::AdminCanceled) { + return true; + } + message_has_terminal_order_status(message) +} + /// Send a direct message to a receiver pub async fn send_dm( client: &Client, @@ -92,7 +138,8 @@ where .pubkey(trade_keys.public_key()) .kind(nostr_sdk::Kind::GiftWrap) .limit(0); - client.subscribe(subscription, Some(opts)).await?; + let subscription_output = client.subscribe(subscription, Some(opts)).await?; + let expected_subscription_id = subscription_output.val; // Send message here after opening notifications to avoid missing messages. sent_message.await?; @@ -101,8 +148,28 @@ where loop { match notifications.recv().await { Ok(notification) => match notification { - RelayPoolNotification::Event { event, .. } => { - return Ok(*event); + RelayPoolNotification::Event { + subscription_id, + event, + .. + } => { + let event = *event; + if event.kind != nostr_sdk::Kind::GiftWrap { + continue; + } + // The same physical GiftWrap may be tagged with a different subscription id + // when both this temporary subscribe and `listen_for_order_messages` match + // the same filter (early trade-key subscribe). Only the listener's id may + // appear on the notification; strict id equality would starve wait_for_dm. + let accept = if subscription_id == expected_subscription_id { + true + } else { + nip59::extract_rumor(trade_keys, &event).await.is_ok() + }; + if !accept { + continue; + } + return Ok(event); } _ => continue, }, @@ -217,8 +284,279 @@ pub async fn parse_dm_events( direct_messages } -/// Continuously listen for messages on trade keys for active orders -/// This function should be spawned as a background task +/// Handle a single decoded trade DM for a given order/trade index. +#[allow(clippy::too_many_arguments)] +async fn handle_trade_dm_for_order( + messages: &Arc>>, + pending_notifications: &Arc>, + message_notification_tx: &tokio::sync::mpsc::UnboundedSender, + order_id: uuid::Uuid, + trade_index: i64, + message: Message, + timestamp: i64, + sender: PublicKey, + pool: &sqlx::SqlitePool, + trade_keys: &Keys, +) { + let inner_kind = message.get_inner_message_kind(); + let action = inner_kind.action.clone(); + + if matches!(&action, Action::AddInvoice) { + if let Some(Payload::Order(ref small_order)) = inner_kind.payload { + let msg_request_id = inner_kind.request_id.and_then(|u| i64::try_from(u).ok()); + match Order::upsert_from_small_order_dm( + pool, + order_id, + small_order.clone(), + trade_keys, + msg_request_id, + ) + .await + { + Ok(_) => log::info!( + "Persisted order {} to database from AddInvoice DM (status={:?})", + order_id, + small_order.status + ), + Err(e) => log::error!( + "Failed to persist order {} from AddInvoice DM: {}", + order_id, + e + ), + } + } + } + + // Extract invoice and sat_amount from payload based on action type + let (sat_amount, invoice) = match &action { + Action::PayInvoice => match &inner_kind.payload { + Some(Payload::PaymentRequest(_, invoice, _)) => (None, Some(invoice.clone())), + _ => (None, None), + }, + Action::AddInvoice => match &inner_kind.payload { + Some(Payload::Order(order)) => (Some(order.amount), None), + _ => (None, None), + }, + _ => (None, None), + }; + + // Persist status: `Payload::Order`, or action-only messages (`canceled` + `payload: null` + // with `id` on [`MessageKind`] — see mostro daemon JSON). + if let Some(Payload::Order(ref order_payload)) = inner_kind.payload { + if let Some(status) = map_action_to_status(&action, order_payload) { + let oid = order_payload.id.or(inner_kind.id).unwrap_or(order_id); + if let Err(e) = update_order_status(pool, &oid.to_string(), status).await { + log::warn!( + "Failed to update status for order {} from DM action {:?}: {}", + oid, + action, + e + ); + } + } + } else if let Some(status) = inferred_status_from_trade_action(&action) { + let oid = inner_kind.id.unwrap_or(order_id); + if let Err(e) = update_order_status(pool, &oid.to_string(), status).await { + log::warn!( + "Failed to update status for order {} from DM action {:?} (no order payload): {}", + oid, + action, + e + ); + } + } + + // Only show PayInvoice popup/notification when an invoice is actually present. + let is_actionable_notification = match &action { + Action::PayInvoice => invoice.as_ref().map(|s| !s.is_empty()).unwrap_or(false), + Action::AddInvoice => true, + _ => true, + }; + + if matches!(action, Action::PayInvoice) && !is_actionable_notification { + return; + } + + // Lock `messages` only long enough to extract comparison data, then drop it + // before touching `pending_notifications` to avoid lock-order deadlocks. + let existing_message_data = { + let messages_lock = messages.lock().unwrap(); + messages_lock + .iter() + .filter(|m| m.order_id == Some(order_id)) + .max_by_key(|m| m.timestamp) + .map(|m| { + ( + m.timestamp, + m.message.get_inner_message_kind().action.clone(), + ) + }) + }; + + // Only increment pending notifications if this is a truly new message. + // Relay delivery can be out-of-order: a later protocol step may carry an older Nostr + // `created_at` than a message we already stored. If we only compared timestamps, + // `waiting-seller-to-pay` after `add-invoice` would not bump the counter. Treat any + // **different action** as a new notification; for the **same** action, require a + // strictly newer timestamp (dedup stale/duplicate events). + let is_new_message = match &existing_message_data { + None => true, + Some((existing_timestamp, existing_action)) => { + if action != *existing_action { + true + } else { + timestamp > *existing_timestamp + } + } + }; + + if is_new_message && is_actionable_notification { + let mut pending_notifications = pending_notifications.lock().unwrap(); + *pending_notifications += 1; + } + + let order_message = crate::ui::OrderMessage { + message: message.clone(), + timestamp, + sender, + order_id: Some(order_id), + trade_index, + read: false, + sat_amount, + buyer_invoice: invoice.clone(), + auto_popup_shown: false, + }; + + let mut messages_lock = messages.lock().unwrap(); + // Keep one row per order, but ensure the newly accepted message is the one kept. + // This avoids dropping same-timestamp/different-action updates during dedup. + messages_lock.retain(|m| m.order_id != Some(order_id)); + messages_lock.push(order_message); + messages_lock.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + let action_str = match &action { + Action::AddInvoice => "Invoice Request", + Action::PayInvoice => "Payment Request", + Action::TakeSell => "Take Sell", + Action::TakeBuy => "Take Buy", + Action::FiatSent => "Fiat Sent", + Action::FiatSentOk => "Fiat Received", + Action::Release | Action::Released => "Release", + Action::Cancel => "Cancel", + Action::Canceled => "Order canceled", + Action::AdminCanceled => "Order canceled by admin", + Action::Dispute | Action::DisputeInitiatedByYou => "Dispute", + Action::WaitingSellerToPay => "Waiting for Seller to Pay", + Action::Rate => "Rate Counterparty", + Action::RateReceived => "Rate Counterparty received", + _ => "New Message", + }; + + let notification = MessageNotification { + order_id: Some(order_id), + message_preview: action_str.to_string(), + timestamp, + action, + sat_amount, + invoice, + }; + + let _ = message_notification_tx.send(notification); +} + +/// How terminal order status is handled after each decoded GiftWrap in a batch. +enum GiftWrapTerminalPolicy<'a> { + /// Known `listen_for_order_messages` subscription: unsubscribe relay sub and stop batch. + TrackedSubscription(&'a SubscriptionId), + /// Unknown subscription id (e.g. parallel `wait_for_dm`): only local index/pubkey cleanup; + /// do not unsubscribe (id not ours). Process the full batch like the pre-refactor path. + UntrackedFallback, +} + +/// Shared path for parsed GiftWrap batches: `handle_trade_dm_for_order` plus terminal cleanup. +#[allow(clippy::too_many_arguments)] +async fn dispatch_giftwrap_batch( + parsed_messages: Vec<(Message, i64, PublicKey)>, + order_id: uuid::Uuid, + trade_index: i64, + trade_keys: &Keys, + messages: &Arc>>, + pending_notifications: &Arc>, + message_notification_tx: &tokio::sync::mpsc::UnboundedSender, + pool: &sqlx::SqlitePool, + user: &User, + active_order_trade_indices: &Arc>>, + subscribed_pubkeys: &mut HashSet, + client: &Client, + subscription_to_order: &mut HashMap, + terminal_policy: GiftWrapTerminalPolicy<'_>, +) { + let log_each_message = matches!( + terminal_policy, + GiftWrapTerminalPolicy::TrackedSubscription(_) + ); + + for (message, timestamp, sender) in parsed_messages { + let has_terminal_status = trade_message_is_terminal(&message); + if log_each_message { + log::info!( + "[dm_listener] Handling message action={:?} ts={} order_id={} trade_index={}", + message.get_inner_message_kind().action, + timestamp, + order_id, + trade_index + ); + } + handle_trade_dm_for_order( + messages, + pending_notifications, + message_notification_tx, + order_id, + trade_index, + message, + timestamp, + sender, + pool, + trade_keys, + ) + .await; + + if has_terminal_status { + match terminal_policy { + GiftWrapTerminalPolicy::TrackedSubscription(subscription_id) => { + log::info!( + "[dm_listener] Terminal order status detected, cleaning up order_id={}, trade_index={}, subscription_id={}", + order_id, + trade_index, + subscription_id + ); + { + let mut indices = active_order_trade_indices.lock().unwrap(); + indices.remove(&order_id); + } + if let Ok(keys) = user.derive_trade_keys(trade_index) { + subscribed_pubkeys.remove(&keys.public_key()); + } + subscription_to_order.remove(subscription_id); + client.unsubscribe(subscription_id).await; + break; + } + GiftWrapTerminalPolicy::UntrackedFallback => { + { + let mut indices = active_order_trade_indices.lock().unwrap(); + indices.remove(&order_id); + } + if let Ok(keys) = user.derive_trade_keys(trade_index) { + subscribed_pubkeys.remove(&keys.public_key()); + } + } + } + } + } +} + +/// Continuously listen for messages on trade keys for active orders using subscriptions. +/// This function should be spawned as a background task. pub async fn listen_for_order_messages( client: Client, pool: sqlx::sqlite::SqlitePool, @@ -226,9 +564,9 @@ pub async fn listen_for_order_messages( messages: Arc>>, message_notification_tx: tokio::sync::mpsc::UnboundedSender, pending_notifications: Arc>, + mut dm_subscription_rx: tokio::sync::mpsc::UnboundedReceiver, ) { - let mut refresh_interval = tokio::time::interval(tokio::time::Duration::from_secs(2)); - // Get user key from db + // Get user key from db (for deriving trade keys) let user = match User::get(&pool).await { Ok(u) => u, Err(e) => { @@ -237,165 +575,248 @@ pub async fn listen_for_order_messages( } }; - loop { - refresh_interval.tick().await; + let mut notifications = client.notifications(); + let mut subscribed_pubkeys: HashSet = HashSet::new(); + let mut subscription_to_order: HashMap = HashMap::new(); - // Get current active orders - let active_orders = { - let indices = active_order_trade_indices.lock().unwrap(); - indices.clone() + // Bootstrap subscriptions for orders already known at startup. + let startup_active_orders = { + let indices = active_order_trade_indices.lock().unwrap(); + indices.clone() + }; + for (order_id, trade_index) in startup_active_orders { + let trade_keys = match user.derive_trade_keys(trade_index) { + Ok(k) => k, + Err(e) => { + log::error!( + "Failed to derive trade keys for startup trade index {}: {}", + trade_index, + e + ); + continue; + } }; - - if active_orders.is_empty() { - continue; - } - - // For each active order, check for new messages - for (order_id, trade_index) in active_orders.iter() { - // Derive trade key for message decode - let trade_keys = match user.derive_trade_keys(*trade_index) { - Ok(k) => k, - Err(e) => { - log::error!( - "Failed to derive trade keys for index {}: {}", - trade_index, - e - ); - continue; - } - }; - - // Fetch recent messages for this trade key - let filter_giftwrap = Filter::new() - .pubkey(trade_keys.public_key()) + let pubkey = trade_keys.public_key(); + if subscribed_pubkeys.insert(pubkey) { + let filter = Filter::new() + .pubkey(pubkey) .kind(nostr_sdk::Kind::GiftWrap) - .limit(5); - - let events = match client - .fetch_events(filter_giftwrap, FETCH_EVENTS_TIMEOUT) - .await - { - Ok(e) => e, + .limit(0); + match client.subscribe(filter, None).await { + Ok(output) => { + subscription_to_order.insert(output.val, (order_id, trade_index)); + } Err(e) => { log::warn!( - "Failed to fetch giftwrap events for trade index {}: {}", + "Failed startup subscribe for trade pubkey {} (index {}): {}", + pubkey, trade_index, e ); - continue; + subscribed_pubkeys.remove(&pubkey); } - }; - - // Parse messages - let parsed_messages = parse_dm_events(events, &trade_keys, None).await; - - // Get only the latest message (with the highest timestamp) - // Index 1 in the tuple is the timestamp - let latest_message = parsed_messages.into_iter().max_by_key(|msg| msg.1); - - // Check if we have new messages - let mut messages_lock = messages.lock().unwrap(); - - if let Some((message, timestamp, sender)) = latest_message { - // Only add if it's a new message - let inner_kind = message.get_inner_message_kind(); - let action = inner_kind.action.clone(); - // Extract invoice and sat_amount from payload based on action type - // PayInvoice: PaymentRequest payload contains invoice - // AddInvoice: Order payload contains sat amount - let (sat_amount, invoice) = match &action { - Action::PayInvoice => { - // For PayInvoice, extract invoice from PaymentRequest payload - match &inner_kind.payload { - Some(Payload::PaymentRequest(_, invoice, _)) => { - (None, Some(invoice.clone())) - } - _ => (None, None), + } + } + } + + loop { + tokio::select! { + cmd = dm_subscription_rx.recv() => { + let Some(cmd) = cmd else { + // Sender dropped; keep listener alive for existing subscriptions. + log::warn!("[dm_listener] dm_subscription_rx closed; no new dynamic subscriptions will be received"); + continue; + }; + + match cmd { + OrderDmSubscriptionCmd::Subscribe { order_id, trade_index } => { + log::info!( + "[dm_listener] Received subscribe command order_id={}, trade_index={}", + order_id, + trade_index + ); + // Must run before any GiftWrap for this trade can hit the unknown- + // subscription_id fallback (e.g. wait_for_dm's temporary subscribe). Main + // thread only inserts this map when take_order completes — too late. + { + let mut indices = active_order_trade_indices.lock().unwrap(); + indices.insert(order_id, trade_index); } - } - Action::AddInvoice => { - // For AddInvoice, extract sat amount from Order payload - match &inner_kind.payload { - Some(Payload::Order(order)) => (Some(order.amount), None), - _ => (None, None), + let trade_keys = match user.derive_trade_keys(trade_index) { + Ok(k) => k, + Err(e) => { + log::error!( + "Failed to derive trade keys for index {}: {}", + trade_index, + e + ); + continue; + } + }; + + let pubkey = trade_keys.public_key(); + if subscribed_pubkeys.insert(pubkey) { + let filter = Filter::new() + .pubkey(pubkey) + .kind(nostr_sdk::Kind::GiftWrap) + .limit(0); + + match client.subscribe(filter, None).await { + Ok(output) => { + log::info!( + "[dm_listener] Subscribed GiftWrap: subscription_id={}, order_id={}, trade_index={}", + output.val, + order_id, + trade_index + ); + subscription_to_order + .insert(output.val, (order_id, trade_index)); + } + Err(e) => { + log::warn!( + "Failed to subscribe for trade pubkey {} (index {}): {}", + pubkey, + trade_index, + e + ); + subscribed_pubkeys.remove(&pubkey); + continue; + } + } } } - _ => (None, None), - }; - // Check if this is a new message for this order_id - // Find the latest message for this order_id (if any exists) - let existing_message = messages_lock - .iter() - .filter(|m| m.order_id == Some(*order_id)) - .max_by_key(|m| m.timestamp); - - // Only increment pending notifications if this is a truly new message - let is_new_message = match existing_message { - None => { - // No message exists for this order_id - this is new - true - } - Some(existing) => { - // Check if the new message is newer than what we already have - // Also check action to avoid counting exact duplicates - let existing_action = - existing.message.get_inner_message_kind().action.clone(); - timestamp > existing.timestamp - || (timestamp == existing.timestamp && action != existing_action) + } + } + notification = notifications.recv() => { + let notification = match notification { + Ok(n) => n, + Err(e) => { + log::warn!("Error receiving relay notification: {:?}", e); + continue; } }; - if is_new_message { - let mut pending_notifications = pending_notifications.lock().unwrap(); - *pending_notifications += 1; - } - - let order_message = crate::ui::OrderMessage { - message: message.clone(), - timestamp, - sender, - order_id: Some(*order_id), - trade_index: *trade_index, - read: false, // New messages are unread by default - sat_amount, - buyer_invoice: invoice.clone(), - auto_popup_shown: false, - }; + if let RelayPoolNotification::Event { + subscription_id, + event, + .. + } = notification + { + let event = *event; + if event.kind != nostr_sdk::Kind::GiftWrap { + continue; + } - // Add to messages list - messages_lock.push(order_message.clone()); - // Sort by time - messages_lock.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); - // Remove duplicates with dedup - messages_lock.dedup_by_key(|a| a.order_id.unwrap()); - - // Create notification - let action_str = match &action { - Action::AddInvoice => "Invoice Request", - Action::PayInvoice => "Payment Request", - Action::TakeSell => "Take Sell", - Action::TakeBuy => "Take Buy", - Action::FiatSent => "Fiat Sent", - Action::FiatSentOk => "Fiat Received", - Action::Release | Action::Released => "Release", - Action::Dispute | Action::DisputeInitiatedByYou => "Dispute", - Action::WaitingSellerToPay => "Waiting for Seller to Pay", - Action::Rate => "Rate Counterparty", - Action::RateReceived => "Rate Counterparty received", - _ => "New Message", - }; + if let Some((order_id, trade_index)) = subscription_to_order.get(&subscription_id).copied() { + log::info!( + "[dm_listener] Routed GiftWrap by subscription_id={} to order_id={}, trade_index={}", + subscription_id, + order_id, + trade_index + ); + + // Derive trade keys again for decryption + let trade_keys = match user.derive_trade_keys(trade_index) { + Ok(k) => k, + Err(e) => { + log::error!( + "Failed to derive trade keys for index {} while handling DM: {}", + trade_index, + e + ); + continue; + } + }; + + let mut events = Events::default(); + events.insert(event.clone()); + + let parsed_messages = parse_dm_events(events, &trade_keys, None).await; + log::info!( + "[dm_listener] Parsed {} message(s) for order_id={}, trade_index={}, subscription_id={}", + parsed_messages.len(), + order_id, + trade_index, + subscription_id + ); + dispatch_giftwrap_batch( + parsed_messages, + order_id, + trade_index, + &trade_keys, + &messages, + &pending_notifications, + &message_notification_tx, + &pool, + &user, + &active_order_trade_indices, + &mut subscribed_pubkeys, + &client, + &mut subscription_to_order, + GiftWrapTerminalPolicy::TrackedSubscription(&subscription_id), + ) + .await; + } else { + // Fallback path: some valid GiftWrap events can arrive under a + // subscription id not tracked by this listener (e.g. parallel wait_for_dm + // temporary subscriptions). Try active trade keys before dropping. + log::info!( + "[dm_listener] Unknown subscription_id={}, trying active trade-key fallback", + subscription_id + ); + let active_orders = { + let indices = active_order_trade_indices.lock().unwrap(); + indices.clone() + }; + + let mut routed = false; + for (order_id, trade_index) in active_orders { + let trade_keys = match user.derive_trade_keys(trade_index) { + Ok(k) => k, + Err(_) => continue, + }; + let mut events = Events::default(); + events.insert(event.clone()); + let parsed_messages = parse_dm_events(events, &trade_keys, None).await; + if parsed_messages.is_empty() { + continue; + } - let notification = MessageNotification { - order_id: Some(*order_id), - message_preview: action_str.to_string(), - timestamp, - action, - sat_amount, - invoice, - }; + log::info!( + "[dm_listener] Fallback routed GiftWrap to order_id={}, trade_index={} (parsed {} message(s))", + order_id, + trade_index, + parsed_messages.len() + ); + dispatch_giftwrap_batch( + parsed_messages, + order_id, + trade_index, + &trade_keys, + &messages, + &pending_notifications, + &message_notification_tx, + &pool, + &user, + &active_order_trade_indices, + &mut subscribed_pubkeys, + &client, + &mut subscription_to_order, + GiftWrapTerminalPolicy::UntrackedFallback, + ) + .await; + routed = true; + break; + } - // Send notification (ignore errors if channel is closed) - let _ = message_notification_tx.send(notification); + if !routed { + log::info!( + "[dm_listener] Fallback failed for unknown subscription_id={}", + subscription_id + ); + } + } + } } } } diff --git a/src/util/dm_utils/notifications_ch_mng.rs b/src/util/dm_utils/notifications_ch_mng.rs index 6e95952..871df43 100644 --- a/src/util/dm_utils/notifications_ch_mng.rs +++ b/src/util/dm_utils/notifications_ch_mng.rs @@ -47,6 +47,7 @@ pub fn handle_message_notification(notification: MessageNotification, app: &mut focused: matches!(notification.action, Action::AddInvoice), just_pasted: false, copied_to_clipboard: false, + scroll_y: 0, }; let action = notification.action.clone(); app.mode = UiMode::NewMessageNotification(notification, action, invoice_state); diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index a638540..fbb83e9 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -6,7 +6,7 @@ use crate::ui::{ use mostro_core::prelude::Action; /// Handle order result from the order result channel -pub fn handle_order_result(result: OperationResult, app: &mut AppState) { +pub fn handle_operation_result(result: OperationResult, app: &mut AppState) { // Handle PaymentRequestRequired - show invoice popup for buy orders if let OperationResult::PaymentRequestRequired { order, @@ -42,6 +42,7 @@ pub fn handle_order_result(result: OperationResult, app: &mut AppState) { focused: false, just_pasted: false, copied_to_clipboard: false, + scroll_y: 0, }; // Reuse pay invoice popup for buy orders when taking an order app.mode = UiMode::NewMessageNotification(notification, Action::PayInvoice, invoice_state); @@ -84,16 +85,21 @@ pub fn handle_order_result(result: OperationResult, app: &mut AppState) { } // Set appropriate result mode based on current state - match app.mode { + match &app.mode { UiMode::UserMode(UserMode::WaitingTakeOrder(_)) => { app.mode = UiMode::OperationResult(result); } UiMode::UserMode(UserMode::WaitingAddInvoice) => { app.mode = UiMode::OperationResult(result); } - UiMode::NewMessageNotification(_, _, _) => { - // If we have a notification, replace it with the result - app.mode = UiMode::OperationResult(result); + UiMode::NewMessageNotification(_, action, _) => { + // Do not replace AddInvoice/PayInvoice popups: the take-order task can finish after + // the DM listener already showed the invoice UI — overwriting would drop the popup. + if matches!(action, Action::AddInvoice | Action::PayInvoice) { + app.pending_post_take_operation_result = Some(result); + } else { + app.mode = UiMode::OperationResult(result); + } } _ => { app.mode = UiMode::OperationResult(result); diff --git a/src/util/mod.rs b/src/util/mod.rs index 600648f..be41ff7 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -15,8 +15,8 @@ pub use blossom::{ pub use chat_utils::send_admin_chat_message_via_shared_key; pub use db_utils::save_order; pub use dm_utils::{ - handle_message_notification, handle_order_result, listen_for_order_messages, parse_dm_events, - send_dm, wait_for_dm, FETCH_EVENTS_TIMEOUT, + handle_message_notification, handle_operation_result, listen_for_order_messages, + parse_dm_events, send_dm, wait_for_dm, OrderDmSubscriptionCmd, FETCH_EVENTS_TIMEOUT, }; pub use filters::{create_filter, create_seven_days_filter}; pub use mostro_info::{ diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index e0d9917..58eca3d 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -66,6 +66,32 @@ pub fn order_from_tags(tags: Tags) -> Result { Ok(order) } +/// Infer `Status` from the message `action` when there is no `SmallOrder` payload +/// (e.g. daemon sends `action: "canceled"` with `payload: null` but `id` on the kind). +pub fn inferred_status_from_trade_action(action: &Action) -> Option { + match action { + Action::Canceled => Some(Status::Canceled), + Action::AdminCanceled => Some(Status::CanceledByAdmin), + Action::FiatSentOk => Some(Status::Success), + Action::Release | Action::Released => Some(Status::Success), + _ => None, + } +} + +/// Map a Mostro `Action` plus the current `SmallOrder` into a new `Status`, +/// when the transition is clear from protocol semantics. +/// +/// For intermediate states where Mostro already sets `order.status` on the +/// `SmallOrder`, callers can simply rely on that value instead of this helper. +pub fn map_action_to_status(action: &Action, order: &SmallOrder) -> Option { + // If the order already has an explicit status from Mostro, prefer that. + if let Some(status) = order.status { + return Some(status); + } + + inferred_status_from_trade_action(action) +} + /// Parse dispute from nostr tags pub fn dispute_from_tags(tags: Tags) -> Result { let mut dispute = Dispute::default(); diff --git a/src/util/order_utils/mod.rs b/src/util/order_utils/mod.rs index 62c5394..122ab9b 100644 --- a/src/util/order_utils/mod.rs +++ b/src/util/order_utils/mod.rs @@ -24,7 +24,8 @@ pub use fetch_scheduler::{ FetchSchedulerResult, }; pub use helper::{ - dispute_from_tags, fetch_events_list, get_disputes, get_orders, order_from_tags, + dispute_from_tags, fetch_events_list, get_disputes, get_orders, + inferred_status_from_trade_action, map_action_to_status, order_from_tags, parse_disputes_events, parse_orders_events, }; pub use send_new_order::send_new_order; diff --git a/src/util/order_utils/send_new_order.rs b/src/util/order_utils/send_new_order.rs index f246a16..a1877b8 100644 --- a/src/util/order_utils/send_new_order.rs +++ b/src/util/order_utils/send_new_order.rs @@ -12,7 +12,9 @@ use crate::util::dm_utils::{parse_dm_events, send_dm, wait_for_dm, FETCH_EVENTS_ use crate::util::order_utils::helper::{ create_order_result_from_form, create_order_result_success, handle_mostro_response, }; +use crate::util::OrderDmSubscriptionCmd; use sqlx::SqlitePool; +use tokio::sync::mpsc::UnboundedSender; /// Send a new order to Mostro pub async fn send_new_order( @@ -20,6 +22,7 @@ pub async fn send_new_order( client: &Client, mostro_pubkey: PublicKey, form: FormState, + dm_subscription_tx: Option<&UnboundedSender>, ) -> Result { // Parse form data let kind_str = if form.kind.trim().is_empty() { @@ -167,11 +170,20 @@ pub async fn send_new_order( request_id, next_idx, pool, + true, ) .await { log::error!("Failed to save order to database: {}", e); } + if let Some(tx) = dm_subscription_tx { + if let Some(order_id) = order.id { + let _ = tx.send(OrderDmSubscriptionCmd::Subscribe { + order_id, + trade_index: next_idx, + }); + } + } Ok(create_order_result_success(order, next_idx)) } else { @@ -207,10 +219,19 @@ pub async fn send_new_order( if let Some(Payload::Order(order)) = &inner_message.payload { // Save order to database if let Err(e) = - save_order(order.clone(), &trade_keys, request_id, next_idx, pool).await + save_order(order.clone(), &trade_keys, request_id, next_idx, pool, true) + .await { log::error!("Failed to save order to database: {}", e); } + if let Some(tx) = dm_subscription_tx { + if let Some(order_id) = order.id { + let _ = tx.send(OrderDmSubscriptionCmd::Subscribe { + order_id, + trade_index: next_idx, + }); + } + } Ok(create_order_result_success(order, next_idx)) } else { diff --git a/src/util/order_utils/take_order.rs b/src/util/order_utils/take_order.rs index e96ea20..82ce64a 100644 --- a/src/util/order_utils/take_order.rs +++ b/src/util/order_utils/take_order.rs @@ -9,6 +9,8 @@ use crate::ui::OperationResult; use crate::util::db_utils::save_order; use crate::util::dm_utils::{parse_dm_events, send_dm, wait_for_dm, FETCH_EVENTS_TIMEOUT}; use crate::util::order_utils::helper::{create_order_result_success, handle_mostro_response}; +use crate::util::OrderDmSubscriptionCmd; +use tokio::sync::mpsc::UnboundedSender; /// Create payload based on action type and parameters fn create_take_order_payload( @@ -34,6 +36,7 @@ fn create_take_order_payload( } /// Take an order from the order book +#[allow(clippy::too_many_arguments)] pub async fn take_order( pool: &sqlx::sqlite::SqlitePool, client: &Client, @@ -42,6 +45,7 @@ pub async fn take_order( order: &SmallOrder, amount: Option, invoice: Option, + dm_subscription_tx: Option<&UnboundedSender>, ) -> Result { // Determine action based on order kind let action = match order.kind { @@ -68,6 +72,20 @@ pub async fn take_order( let trade_keys = user.derive_trade_keys(next_idx)?; let _ = User::update_last_trade_index(pool, next_idx).await; + // Subscribe as early as possible for take-order flow so the first + // Mostro response/event is not missed by the background DM listener. + if let Some(tx) = dm_subscription_tx { + log::info!( + "[take_order] Early subscribe command for order_id={}, trade_index={}", + order_id, + next_idx + ); + let _ = tx.send(OrderDmSubscriptionCmd::Subscribe { + order_id, + trade_index: next_idx, + }); + } + // Create payload based on action type let payload = create_take_order_payload(action.clone(), &invoice, amount)?; @@ -123,30 +141,70 @@ pub async fn take_order( // Request ID matches, process the response match &inner_message.payload { Some(Payload::Order(returned_order)) => { + let mut normalized_order = returned_order.clone(); + if normalized_order.id.is_none() { + log::warn!( + "[take_order] Mostro response Order payload missing id; falling back to requested order_id={}", + order_id + ); + normalized_order.id = Some(order_id); + } + let effective_order_id = normalized_order.id.unwrap_or(order_id); + log::info!( + "[take_order] Action::Order response mapped to effective_order_id={}, trade_index={}", + effective_order_id, + next_idx + ); + // Save order to database if let Err(e) = save_order( - returned_order.clone(), + normalized_order.clone(), &trade_keys, request_id, next_idx, pool, + false, ) .await { log::error!("Failed to save order to database: {}", e); } - Ok(create_order_result_success(returned_order, next_idx)) + if let Some(tx) = dm_subscription_tx { + log::info!( + "[take_order] Sending DM subscription command for order_id={}, trade_index={}", + effective_order_id, + next_idx + ); + let _ = tx.send(OrderDmSubscriptionCmd::Subscribe { + order_id: effective_order_id, + trade_index: next_idx, + }); + } + Ok(create_order_result_success(&normalized_order, next_idx)) } Some(Payload::PaymentRequest(opt_order, invoice_string, opt_amount)) => { // For buy orders, we receive PaymentRequest with invoice for seller to pay // Use the order from payload if available, otherwise use the original order - let order_to_save = if let Some(order_to_save) = opt_order { - order_to_save + let mut order_to_save = if let Some(order_to_save) = opt_order { + order_to_save.clone() } else { return Err(anyhow::anyhow!( "Order details are missing from payload" )); }; + if order_to_save.id.is_none() { + log::warn!( + "[take_order] Mostro PaymentRequest payload order missing id; falling back to requested order_id={}", + order_id + ); + order_to_save.id = Some(order_id); + } + let effective_order_id = order_to_save.id.unwrap_or(order_id); + log::info!( + "[take_order] Action::PaymentRequest response mapped to effective_order_id={}, trade_index={}", + effective_order_id, + next_idx + ); // Save order to database if let Err(e) = save_order( @@ -155,11 +213,23 @@ pub async fn take_order( request_id, next_idx, pool, + false, ) .await { log::error!("Failed to save order to database: {}", e); } + if let Some(tx) = dm_subscription_tx { + log::info!( + "[take_order] Sending DM subscription command for order_id={}, trade_index={}", + effective_order_id, + next_idx + ); + let _ = tx.send(OrderDmSubscriptionCmd::Subscribe { + order_id: effective_order_id, + trade_index: next_idx, + }); + } log::info!( "Received PaymentRequest for buy order {} with invoice", diff --git a/tests/db_tests.rs b/tests/db_tests.rs index 160b527..01bac52 100644 --- a/tests/db_tests.rs +++ b/tests/db_tests.rs @@ -109,7 +109,7 @@ async fn test_order_new() { None, ); - let order = Order::new(&pool, small_order.clone(), &trade_keys, Some(123)) + let order = Order::new(&pool, small_order.clone(), &trade_keys, Some(123), true) .await .unwrap(); @@ -135,7 +135,7 @@ async fn test_order_get_by_id() { small_order.payment_method = "paypal".to_string(); small_order.premium = 3; - let created_order = Order::new(&pool, small_order, &trade_keys, None) + let created_order = Order::new(&pool, small_order, &trade_keys, None, true) .await .unwrap(); let order_id_str = created_order.id.as_ref().unwrap(); @@ -171,14 +171,14 @@ async fn test_order_update_existing() { small_order.premium = 5; // Create order - let order1 = Order::new(&pool, small_order.clone(), &trade_keys, None) + let order1 = Order::new(&pool, small_order.clone(), &trade_keys, None, true) .await .unwrap(); // Update with same ID but different data small_order.amount = 200000; small_order.fiat_amount = 200; - let order2 = Order::new(&pool, small_order, &trade_keys, None) + let order2 = Order::new(&pool, small_order, &trade_keys, None, true) .await .unwrap();