diff --git a/src/main.rs b/src/main.rs index 8ed9fec..0ac6b08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -431,7 +431,12 @@ async fn main() -> Result<(), anyhow::Error> { let is_dispute_related = matches!(&result, OperationResult::Info(msg) if (msg.contains("Dispute") && msg.contains("taken successfully")) || (msg.contains("Dispute") && (msg.contains("settled") || msg.contains("canceled")))); - let resync_my_trades_from_db = matches!(&result, OperationResult::OrderHistoryDeleted { .. }); + // Only bulk-delete should rehydrate Messages + `order_chat_static` from DB: + // `sync_user_order_history_messages_from_db` replaces per-order rows with + // synthetic TakeBuy/TakeSell actions, which breaks Messages-tab Enter / invoice + // flows. Do not tie this to arbitrary `OperationResult::Success`. + let resync_my_trades_from_db = + matches!(&result, OperationResult::OrderHistoryDeleted { .. }); handle_operation_result(result, &mut app); if resync_my_trades_from_db && app.user_role == UserRole::User { diff --git a/src/ui/helpers/startup.rs b/src/ui/helpers/startup.rs index 646677e..5738ff1 100644 --- a/src/ui/helpers/startup.rs +++ b/src/ui/helpers/startup.rs @@ -212,7 +212,8 @@ fn db_order_to_history_message(order: &Order, sender: PublicKey) -> Option } } 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)); + let popup_message = message_action_compact_label_for_message(msg).to_string(); + app.mode = UiMode::OperationResult(OperationResult::Info(popup_message)); } } } else if let Tab::Admin(AdminTab::Observer) = app.active_tab { diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 63f1130..98baff8 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use mostro_core::prelude::*; use nostr_sdk::prelude::*; use ratatui::style::{Color, Style}; @@ -40,6 +42,76 @@ pub struct OrderSuccess { pub static_header: Option, } +/// `action` for a post-success placeholder [`OrderMessage`] so My Trades has a row before DMs land. +/// Never returns synthetic book-side `take-buy` / `take-sell` (those break Messages-tab Enter). +fn placeholder_action_for_order_success(os: &OrderSuccess) -> Option { + let header = os.static_header.as_ref()?; + if header.is_mine { + return Some(Action::NewOrder); + } + match os.status { + Some(Status::WaitingTakerBond) => Some(Action::PayBondInvoice), + Some(Status::WaitingPayment) => Some(Action::WaitingSellerToPay), + Some(Status::WaitingBuyerInvoice) | Some(Status::SettledHoldInvoice) => { + Some(Action::WaitingBuyerInvoice) + } + Some(Status::FiatSent) => Some(Action::FiatSent), + _ => Some(Action::BuyerTookOrder), + } +} + +fn small_order_from_order_success(os: &OrderSuccess) -> SmallOrder { + SmallOrder { + id: os.order_id, + kind: os.kind, + status: os.status, + amount: os.amount, + fiat_code: os.fiat_code.clone(), + min_amount: os.min_amount, + max_amount: os.max_amount, + fiat_amount: os.fiat_amount, + payment_method: os.payment_method.clone(), + premium: os.premium, + buyer_invoice: None, + created_at: os.static_header.as_ref().and_then(|h| h.created_at), + expires_at: None, + ..Default::default() + } +} + +/// One synthetic [`OrderMessage`] when `Success` arrives before any DM row exists (My Trades sidebar). +pub(crate) fn try_placeholder_order_message_from_success( + os: &OrderSuccess, +) -> Option { + let header = os.static_header.as_ref()?; + let order_id = os.order_id?; + let trade_index = os.trade_index.unwrap_or(header.trade_index); + let action = placeholder_action_for_order_success(os)?; + let sender = PublicKey::from_str(header.initiator_trade_pubkey.as_str()).ok()?; + let small = small_order_from_order_success(os); + let message = Message::new_order( + Some(order_id), + None, + Some(trade_index), + action, + Some(Payload::Order(small)), + ); + Some(OrderMessage { + message, + timestamp: chrono::Utc::now().timestamp(), + sender, + order_id: Some(order_id), + trade_index, + sat_amount: None, + buyer_invoice: None, + order_kind: os.kind, + is_mine: Some(header.is_mine), + order_status: os.status, + read: true, + auto_popup_shown: true, + }) +} + /// Per-order buyer invoice preference when we act as taker on a SELL listing. /// Stored in-memory only (not persisted to DB); later flows can use it to /// decide how to pre-fill or submit buyer invoices for that specific order. @@ -456,6 +528,7 @@ pub fn message_action_compact_label(action: &Action) -> &'static str { /// Keeps terminal statuses from showing stale action text after reboot replay. pub fn message_action_compact_label_for_message(msg: &OrderMessage) -> &'static str { match msg.order_status { + Some(Status::Pending) => "Pending order", Some(Status::Success) => "Trade Completed", Some(Status::SettledByAdmin) => "Settled by admin", Some(Status::CompletedByAdmin) => "Completed by admin", @@ -500,6 +573,7 @@ pub fn message_order_kind_label(msg: &OrderMessage) -> &'static str { #[repr(u8)] #[allow(clippy::enum_variant_names)] // Step* names match UI column semantics pub enum StepLabelsBuy { + StepPendingOrder = 0, StepSellerPayment = 1, StepBuyerInvoice = 2, StepChatActiveOrder = 3, @@ -512,6 +586,7 @@ pub enum StepLabelsBuy { #[repr(u8)] #[allow(clippy::enum_variant_names)] // Step* names match UI column semantics pub enum StepLabelsSell { + StepPendingOrder = 0, StepBuyerInvoice = 1, StepSellerPayment = 2, StepChatActiveOrder = 3, @@ -593,11 +668,13 @@ pub fn sell_listing_flow_step(msg: &OrderMessage) -> FlowStep { fn listing_step_from_status(kind: mostro_core::order::Kind, status: Status) -> Option { match kind { mostro_core::order::Kind::Buy => match status { + // Initial stat of order - no green steps visulized, orders is still pending. + Status::Pending | Status::WaitingTakerBond => { + Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepPendingOrder)) + } // `WaitingTakerBond` (Mostro Phase 1.5+): order matched but trade flow has not // started; treat like `Pending` for the timeline. - Status::Pending | Status::WaitingTakerBond | Status::WaitingPayment => { - Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepSellerPayment)) - } + Status::WaitingPayment => Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepSellerPayment)), Status::WaitingBuyerInvoice | Status::SettledHoldInvoice => { Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepBuyerInvoice)) } @@ -614,12 +691,18 @@ fn listing_step_from_status(kind: mostro_core::order::Kind, status: Status) -> O | Status::Expired | Status::Dispute | Status::SettledByAdmin - | Status::CompletedByAdmin => None, + | Status::CompletedByAdmin => { + Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepPendingOrder)) + } }, mostro_core::order::Kind::Sell => match status { + // Initial stat of order - no green steps visulized, orders is still pending. + Status::Pending | Status::WaitingTakerBond => { + Some(FlowStep::SellFlowStep(StepLabelsSell::StepPendingOrder)) + } // `WaitingTakerBond` (Mostro Phase 1.5+): order matched but trade flow has not // started; treat like `Pending` for the timeline. - Status::Pending | Status::WaitingTakerBond | Status::WaitingPayment => { + Status::WaitingPayment => { Some(FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment)) } Status::WaitingBuyerInvoice | Status::SettledHoldInvoice => { @@ -638,7 +721,9 @@ fn listing_step_from_status(kind: mostro_core::order::Kind, status: Status) -> O | Status::Expired | Status::Dispute | Status::SettledByAdmin - | Status::CompletedByAdmin => None, + | Status::CompletedByAdmin => { + Some(FlowStep::SellFlowStep(StepLabelsSell::StepPendingOrder)) + } }, } } @@ -808,6 +893,82 @@ pub fn apply_kind_color(kind: &mostro_core::order::Kind) -> Style { } } +#[cfg(test)] +mod order_success_placeholder_tests { + use super::*; + use nostr_sdk::Keys; + + fn sample_order_success( + is_mine: bool, + status: Option, + kind: Option, + ) -> OrderSuccess { + let keys = Keys::generate(); + let order_id = uuid::Uuid::new_v4(); + OrderSuccess { + order_id: Some(order_id), + kind, + amount: 100, + fiat_code: "EUR".to_string(), + fiat_amount: 50, + min_amount: None, + max_amount: None, + payment_method: "SEPA".to_string(), + premium: 0, + status, + trade_index: Some(1), + static_header: Some(OrderChatStaticHeader { + order_id, + kind, + created_at: Some(1), + trade_index: 1, + initiator_trade_pubkey: keys.public_key().to_string(), + is_mine, + }), + } + } + + #[test] + fn maker_placeholder_uses_new_order_action() { + let os = sample_order_success( + true, + Some(Status::Pending), + Some(mostro_core::order::Kind::Sell), + ); + let msg = try_placeholder_order_message_from_success(&os).expect("msg"); + assert_eq!( + msg.message.get_inner_message_kind().action, + Action::NewOrder + ); + assert_eq!(msg.order_status, Some(Status::Pending)); + } + + #[test] + fn taker_waiting_buyer_invoice_uses_waiting_buyer_invoice_action() { + let os = sample_order_success( + false, + Some(Status::WaitingBuyerInvoice), + Some(mostro_core::order::Kind::Sell), + ); + let msg = try_placeholder_order_message_from_success(&os).expect("msg"); + assert_eq!( + msg.message.get_inner_message_kind().action, + Action::WaitingBuyerInvoice + ); + } + + #[test] + fn skips_without_static_header() { + let mut os = sample_order_success( + true, + Some(Status::Pending), + Some(mostro_core::order::Kind::Sell), + ); + os.static_header = None; + assert!(try_placeholder_order_message_from_success(&os).is_none()); + } +} + #[cfg(test)] mod timeline_step_tests { use super::*; @@ -923,7 +1084,7 @@ mod timeline_step_tests { } #[test] - fn buy_taker_pay_bond_invoice_maps_to_seller_payment_step() { + fn buy_taker_pay_bond_invoice_waiting_taker_bond_maps_to_pending_order_step() { let m = sample_order_message( Action::PayBondInvoice, Some(mostro_core::order::Kind::Buy), @@ -932,12 +1093,12 @@ mod timeline_step_tests { ); assert_eq!( message_trade_timeline_step(&m), - FlowStep::BuyFlowStep(StepLabelsBuy::StepSellerPayment) + FlowStep::BuyFlowStep(StepLabelsBuy::StepPendingOrder) ); } #[test] - fn sell_taker_pay_bond_invoice_maps_to_seller_payment_step() { + fn sell_taker_pay_bond_invoice_waiting_taker_bond_maps_to_pending_order_step() { let m = sample_order_message( Action::PayBondInvoice, Some(mostro_core::order::Kind::Sell), @@ -946,7 +1107,7 @@ mod timeline_step_tests { ); assert_eq!( message_trade_timeline_step(&m), - FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment) + FlowStep::SellFlowStep(StepLabelsSell::StepPendingOrder) ); } } diff --git a/src/util/dm_utils/mod.rs b/src/util/dm_utils/mod.rs index bb5fc86..7dfe54d 100644 --- a/src/util/dm_utils/mod.rs +++ b/src/util/dm_utils/mod.rs @@ -432,7 +432,7 @@ async fn handle_trade_dm_for_order( ) { let inner_kind = message.get_inner_message_kind(); let action = inner_kind.action.clone(); - if matches!(action, Action::NewOrder) { + if matches!(action, Action::NewOrder | Action::CantDo) { return; } diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index a18fcac..31ecd60 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -1,7 +1,8 @@ // Order channel manager - handles order result messages from async tasks use crate::ui::helpers::build_active_order_chat_list; use crate::ui::orders::{ - strip_new_order_messages_and_clamp_selected, BuyerInvoicePreference, OrderSuccess, + strip_new_order_messages_and_clamp_selected, try_placeholder_order_message_from_success, + BuyerInvoicePreference, OrderSuccess, }; use crate::ui::{ AppState, InvoiceInputState, InvoiceNotificationActionSelection, MessageNotification, @@ -85,6 +86,35 @@ fn remove_many_orders_from_messages_tab(app: &mut AppState, order_ids: &[Uuid]) } } +/// If `Success` arrived before any DM row exists for this trade, append one placeholder so +/// **Orders In Progress** (`build_active_order_chat_list`) has a sidebar row without running +/// `sync_user_order_history_messages_from_db` (which would clobber real actions). +fn maybe_insert_my_trade_placeholder_message(app: &mut AppState, os: &OrderSuccess) { + let Some(order_id) = os.order_id else { + return; + }; + if os.static_header.is_none() { + return; + } + let Some(placeholder) = try_placeholder_order_message_from_success(os) else { + return; + }; + match app.messages.lock() { + Ok(mut messages) => { + if messages.iter().any(|m| m.order_id == Some(order_id)) { + return; + } + messages.push(placeholder); + messages.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + } + Err(e) => { + crate::util::request_fatal_restart(format!( + "Mostrix encountered an internal error (poisoned messages lock: {e}). Please restart the app." + )); + } + } +} + /// Handle order result from the order result channel pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) { if let OperationResult::TradeClosed { order_id, message } = result { @@ -116,6 +146,7 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) if let Some(h) = &os.static_header { app.order_chat_static.insert(h.order_id, h.clone()); } + maybe_insert_my_trade_placeholder_message(app, os); } OperationResult::PaymentRequestRequired { static_header, .. } => { app.order_chat_static