Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/ui/helpers/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ fn db_order_to_history_message(order: &Order, sender: PublicKey) -> Option<Order
is_mine: Some(order.is_mine),
order_status: status,
read: true,
auto_popup_shown: true,
// This is need to avoid the missing visualization of the invoice popup when the order is in the WaitingBuyerInvoice status
auto_popup_shown: !matches!(status, Some(Status::WaitingBuyerInvoice)),
};
Some(history_message)
}
Expand Down
8 changes: 4 additions & 4 deletions src/ui/key_handler/enter_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use crate::ui::key_handler::input_helpers::{
prepare_admin_chat_message, send_admin_chat_message_via_shared_key,
};
use crate::ui::orders::{
invoice_popup_allowed_for_order_status, strip_new_order_messages_and_clamp_selected,
invoice_popup_allowed_for_order_status, message_action_compact_label_for_message,
strip_new_order_messages_and_clamp_selected,
};
use crate::ui::{
order_message_to_notification, AdminMode, AdminTab, AppState, ChatParty, InvoiceInputState,
Expand Down Expand Up @@ -1051,9 +1052,8 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_>
}
} 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 {
Expand Down
181 changes: 171 additions & 10 deletions src/ui/orders.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::str::FromStr;

use mostro_core::prelude::*;
use nostr_sdk::prelude::*;
use ratatui::style::{Color, Style};
Expand Down Expand Up @@ -40,6 +42,76 @@ pub struct OrderSuccess {
pub static_header: Option<OrderChatStaticHeader>,
}

/// `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<Action> {
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<OrderMessage> {
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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<FlowStep> {
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))
}
Expand All @@ -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 => {
Expand All @@ -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))
}
Comment thread
arkanoider marked this conversation as resolved.
},
}
}
Expand Down Expand Up @@ -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<Status>,
kind: Option<mostro_core::order::Kind>,
) -> 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::*;
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -946,7 +1107,7 @@ mod timeline_step_tests {
);
assert_eq!(
message_trade_timeline_step(&m),
FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment)
FlowStep::SellFlowStep(StepLabelsSell::StepPendingOrder)
);
}
}
2 changes: 1 addition & 1 deletion src/util/dm_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
33 changes: 32 additions & 1 deletion src/util/dm_utils/order_ch_mng.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading