diff --git a/src/db.rs b/src/db.rs index cabd6a64..73c4c3f8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -47,6 +47,9 @@ pub async fn init_db() -> Result { trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, @@ -167,6 +170,10 @@ async fn migrate_db(pool: &SqlitePool) -> Result<()> { let has_last_seen_dm_ts = check_column_exists(pool, "orders", "last_seen_dm_ts").await?; let has_order_chat_shared_key_hex = check_column_exists(pool, "orders", "order_chat_shared_key_hex").await?; + let has_order_dispute_id = check_column_exists(pool, "orders", "dispute_id").await?; + let has_solver_pubkey = check_column_exists(pool, "orders", "solver_pubkey").await?; + let has_dispute_chat_shared_key_hex = + check_column_exists(pool, "orders", "dispute_chat_shared_key_hex").await?; // Only run migration if at least one column is missing if !has_initiator_info @@ -181,8 +188,11 @@ async fn migrate_db(pool: &SqlitePool) -> Result<()> { || !has_trade_index || !has_last_seen_dm_ts || !has_order_chat_shared_key_hex + || !has_order_dispute_id + || !has_solver_pubkey + || !has_dispute_chat_shared_key_hex { - log::info!("Running migration: Adding missing columns to admin_disputes table"); + log::info!("Running migration: adding missing database columns"); // Wrap all ALTER TABLE statements in a transaction for atomicity let mut tx = pool.begin().await?; @@ -317,6 +327,24 @@ async fn migrate_db(pool: &SqlitePool) -> Result<()> { .await?; } + if !has_order_dispute_id { + sqlx::query("ALTER TABLE orders ADD COLUMN dispute_id TEXT") + .execute(&mut *tx) + .await?; + } + + if !has_solver_pubkey { + sqlx::query("ALTER TABLE orders ADD COLUMN solver_pubkey TEXT") + .execute(&mut *tx) + .await?; + } + + if !has_dispute_chat_shared_key_hex { + sqlx::query("ALTER TABLE orders ADD COLUMN dispute_chat_shared_key_hex TEXT") + .execute(&mut *tx) + .await?; + } + tx.commit().await?; log::info!("Migration completed successfully"); } @@ -377,6 +405,9 @@ async fn orders_table_rebuild_without_suppress_column(pool: &SqlitePool) -> Resu trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, @@ -393,12 +424,14 @@ async fn orders_table_rebuild_without_suppress_column(pool: &SqlitePool) -> Resu r#" INSERT INTO orders_new ( id, kind, status, amount, fiat_code, min_amount, max_amount, fiat_amount, - payment_method, premium, trade_keys, counterparty_pubkey, order_chat_shared_key_hex, is_mine, buyer_invoice, + payment_method, premium, trade_keys, counterparty_pubkey, order_chat_shared_key_hex, + dispute_id, solver_pubkey, dispute_chat_shared_key_hex, is_mine, buyer_invoice, request_id, trade_index, created_at, expires_at, last_seen_dm_ts ) SELECT id, kind, status, amount, fiat_code, min_amount, max_amount, fiat_amount, - payment_method, premium, trade_keys, counterparty_pubkey, order_chat_shared_key_hex, is_mine, buyer_invoice, + payment_method, premium, trade_keys, counterparty_pubkey, order_chat_shared_key_hex, + dispute_id, solver_pubkey, dispute_chat_shared_key_hex, is_mine, buyer_invoice, request_id, trade_index, created_at, expires_at, last_seen_dm_ts FROM orders; "#, diff --git a/src/models.rs b/src/models.rs index 4c37952f..ccb4ca0e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -164,6 +164,12 @@ pub struct Order { pub counterparty_pubkey: Option, /// ECDH shared secret for P2P order chat (hex), derived once when both trade pubkeys are known. pub order_chat_shared_key_hex: Option, + /// Dispute UUID assigned by Mostro for this order. + pub dispute_id: Option, + /// Trade pubkey of the solver that took the dispute. + pub solver_pubkey: Option, + /// ECDH shared secret for the user-to-solver dispute chat. + pub dispute_chat_shared_key_hex: Option, /// Maker (`true`) vs taker (`false`). Matches `orders.is_mine` INTEGER NOT NULL (0/1). pub is_mine: bool, pub buyer_invoice: Option, @@ -261,6 +267,9 @@ impl Order { trade_keys: Some(trade_keys_hex), counterparty_pubkey: None, order_chat_shared_key_hex: None, + dispute_id: None, + solver_pubkey: None, + dispute_chat_shared_key_hex: None, is_mine: is_maker, buyer_invoice: order.buyer_invoice, request_id: _request_id, @@ -298,8 +307,10 @@ impl Order { r#" INSERT INTO orders (id, kind, status, amount, min_amount, max_amount, fiat_code, fiat_amount, payment_method, premium, is_mine, - trade_keys, counterparty_pubkey, order_chat_shared_key_hex, buyer_invoice, request_id, trade_index, created_at, expires_at, last_seen_dm_ts) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + trade_keys, counterparty_pubkey, order_chat_shared_key_hex, + dispute_id, solver_pubkey, dispute_chat_shared_key_hex, + buyer_invoice, request_id, trade_index, created_at, expires_at, last_seen_dm_ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&self.id) @@ -316,6 +327,9 @@ impl Order { .bind(&self.trade_keys) .bind(&self.counterparty_pubkey) .bind(&self.order_chat_shared_key_hex) + .bind(&self.dispute_id) + .bind(&self.solver_pubkey) + .bind(&self.dispute_chat_shared_key_hex) .bind(&self.buyer_invoice) .bind(self.request_id) .bind(self.trade_index) @@ -333,7 +347,8 @@ impl Order { UPDATE orders SET kind = ?, status = ?, amount = ?, min_amount = ?, max_amount = ?, fiat_code = ?, fiat_amount = ?, payment_method = ?, premium = ?, - is_mine = ?, trade_keys = ?, counterparty_pubkey = ?, order_chat_shared_key_hex = ?, buyer_invoice = ?, + is_mine = ?, trade_keys = ?, counterparty_pubkey = ?, order_chat_shared_key_hex = ?, + dispute_id = ?, solver_pubkey = ?, dispute_chat_shared_key_hex = ?, buyer_invoice = ?, request_id = ?, trade_index = ?, created_at = ?, expires_at = ?, last_seen_dm_ts = ? WHERE id = ? "#, @@ -351,6 +366,9 @@ impl Order { .bind(&self.trade_keys) .bind(&self.counterparty_pubkey) .bind(&self.order_chat_shared_key_hex) + .bind(&self.dispute_id) + .bind(&self.solver_pubkey) + .bind(&self.dispute_chat_shared_key_hex) .bind(&self.buyer_invoice) .bind(self.request_id) .bind(self.trade_index) @@ -397,6 +415,10 @@ impl Order { trade_keys: Some(trade_keys_hex), counterparty_pubkey, order_chat_shared_key_hex, + dispute_id: existing.and_then(|e| e.dispute_id.clone()), + solver_pubkey: existing.and_then(|e| e.solver_pubkey.clone()), + dispute_chat_shared_key_hex: existing + .and_then(|e| e.dispute_chat_shared_key_hex.clone()), is_mine: existing.map(|e| e.is_mine).unwrap_or(true), buyer_invoice: small_order.buyer_invoice.clone(), request_id: message_request_id.or_else(|| existing.and_then(|e| e.request_id)), @@ -534,6 +556,38 @@ impl Order { Ok(()) } + /// Persist the dispute id announced by Mostro for a user order. + pub async fn update_dispute_id( + pool: &SqlitePool, + order_id: &str, + dispute_id: &str, + ) -> Result<()> { + sqlx::query("UPDATE orders SET dispute_id = ? WHERE id = ?") + .bind(dispute_id) + .bind(order_id) + .execute(pool) + .await?; + Ok(()) + } + + /// Persist the assigned solver and the derived user-to-solver chat secret. + pub async fn update_solver_chat( + pool: &SqlitePool, + order_id: &str, + solver_pubkey: &str, + shared_key_hex: &str, + ) -> Result<()> { + sqlx::query( + "UPDATE orders SET solver_pubkey = ?, dispute_chat_shared_key_hex = ? WHERE id = ?", + ) + .bind(solver_pubkey) + .bind(shared_key_hex) + .bind(order_id) + .execute(pool) + .await?; + Ok(()) + } + pub async fn get_startup_active_orders( pool: &SqlitePool, ) -> Result> { diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 866499ff..000b58cb 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -12,7 +12,7 @@ use crate::models::AdminDispute; use crate::ui::admin_state::AdminMode; use crate::ui::chat::{ AdminChatLastSeen, ChatParty, DisputeChatMessage, DisputeFilter, OrderChatLastSeen, - UserOrderChatMessage, + UserChatChannel, UserOrderChatMessage, }; use crate::ui::helpers::OrderChatListItem; use crate::ui::navigation::{AdminTab, Tab, UserRole}; @@ -230,11 +230,16 @@ pub struct AppState { /// Maker `pending` listings on the book without a trade-DM row in Messages (refreshed on events). pub my_trades_maker_book: Vec, pub order_chats: HashMap>, // Chat messages per order id + /// User-to-solver dispute messages per order id. + pub user_dispute_chats: HashMap>, + /// Active My Trades conversation for the selected order. + pub active_user_chat_channel: UserChatChannel, pub order_chat_scrollview_state: tui_scrollview::ScrollViewState, pub order_chat_selected_message_idx: Option, pub order_chat_line_starts: Vec, - pub order_chat_scroll_tracker: Option<(String, usize)>, + pub order_chat_scroll_tracker: Option<(String, UserChatChannel, usize)>, pub order_chat_last_seen: HashMap, + pub user_dispute_chat_last_seen: HashMap, pub pending_notifications: Arc>, // Count of pending notifications (non-critical) pub admin_disputes_in_progress: Vec, // Taken disputes pub dispute_filter: DisputeFilter, // Filter for viewing InProgress or Finalized disputes @@ -336,11 +341,14 @@ impl AppState { order_chat_static: HashMap::new(), my_trades_maker_book: Vec::new(), order_chats: HashMap::new(), + user_dispute_chats: HashMap::new(), + active_user_chat_channel: UserChatChannel::Peer, order_chat_scrollview_state: tui_scrollview::ScrollViewState::default(), order_chat_selected_message_idx: None, order_chat_line_starts: Vec::new(), order_chat_scroll_tracker: None, order_chat_last_seen: HashMap::new(), + user_dispute_chat_last_seen: HashMap::new(), pending_notifications: Arc::new(Mutex::new(0)), admin_disputes_in_progress: Vec::new(), dispute_filter: DisputeFilter::InProgress, // Default to InProgress view diff --git a/src/ui/chat.rs b/src/ui/chat.rs index 9c584e39..df43f35c 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -39,6 +39,25 @@ pub enum UserChatSender { Peer, } +/// User-facing chat channel selected in My Trades. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum UserChatChannel { + /// Chat with the order counterparty. + #[default] + Peer, + /// Chat with the solver assigned to the dispute. + Solver, +} + +impl Display for UserChatChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Peer => write!(f, "Peer"), + Self::Solver => write!(f, "Solver"), + } + } +} + /// Type of file attachment (Mostro Mobile image_encrypted / file_encrypted). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChatAttachmentType { @@ -114,6 +133,8 @@ pub struct OrderChatLastSeen { #[derive(Clone, Debug)] pub struct OrderChatUpdate { pub order_id: String, + /// Conversation that should receive this batch. + pub channel: UserChatChannel, /// Local trade public key for this order; used to skip relay echoes of our own sends. pub local_trade_pubkey: PublicKey, pub messages: Vec, diff --git a/src/ui/constants.rs b/src/ui/constants.rs index c2fca2a7..16eac65d 100644 --- a/src/ui/constants.rs +++ b/src/ui/constants.rs @@ -91,6 +91,7 @@ pub const HELP_ORDERS_CANCEL_PENDING_MSG: &str = // Help popup lines (My Trades) pub const HELP_MY_TRADES_NAV: &str = "↑↓: Select order"; pub const HELP_MY_TRADES_ENTER_SEND: &str = "Enter: Send message (when input enabled)"; +pub const HELP_MY_TRADES_TAB_CHAT: &str = "Tab: Switch Peer/Solver chat (after solver assignment)"; pub const HELP_MY_TRADES_SHIFT_I: &str = "Shift+I: Enable/disable message input"; pub const HELP_MY_TRADES_SHIFT_C_CANCEL: &str = "Shift+C: Cancel order (cooperative cancel)"; pub const HELP_MY_TRADES_SHIFT_F_FIAT_SENT: &str = "Shift+F: Mark fiat as sent (FiatSent message)"; @@ -177,6 +178,7 @@ pub const FOOTER_PGUP_PGDN_SCROLL_CHAT: &str = "PgUp/PgDn: Scroll Chat"; // --- Footer (My Trades / Order Chat) --- pub const FOOTER_MYTRADES_SELECT_ORDER: &str = "↑↓: Select order"; +pub const FOOTER_MYTRADES_TAB_CHAT: &str = "Tab: Peer/Solver chat"; pub const FOOTER_MYTRADES_ENTER_SEND: &str = "Enter: Send"; pub const FOOTER_MYTRADES_SHIFT_I_DISABLE: &str = "Shift+I: Disable input"; pub const FOOTER_MYTRADES_SHIFT_I_ENABLE: &str = "Shift+I: Enable input"; diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 2d13cb66..c5c0df0b 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -7,10 +7,18 @@ use super::constants::*; use super::{AppState, DisputeFilter, BACKGROUND_COLOR, PRIMARY_COLOR}; use crate::ui::navigation::{AdminTab, Tab, UserRole, UserTab}; +// 13 shortcuts, intro, close hint, borders, and one row of margin above and below. +const MY_TRADES_FULL_HELP_MIN_HEIGHT: u16 = 19; +const MY_TRADES_FULL_HELP_MIN_WIDTH: u16 = 60; + /// Renders the context-aware keyboard shortcuts popup (Ctrl+H, and Shift+H on My Trades). pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { let area = f.area(); let (title, plain_lines) = help_content(app, tab); + let narrow_my_trades = + matches!(tab, Tab::User(UserTab::MyTrades)) && area.width < MY_TRADES_FULL_HELP_MIN_WIDTH; + let compact_my_trades = matches!(tab, Tab::User(UserTab::MyTrades)) + && (area.height < MY_TRADES_FULL_HELP_MIN_HEIGHT || narrow_my_trades); // Match Settings Shift+H: compact rows, styled shortcut + description, full viewport height. let compact_chrome = matches!( @@ -56,11 +64,15 @@ pub fn render_help_popup(f: &mut ratatui::Frame, app: &AppState, tab: Tab) { let mut lines: Vec> = Vec::new(); if matches!(tab, Tab::Admin(AdminTab::DisputesInProgress)) { lines.push(help_disputes_in_progress_intro()); + } else if compact_my_trades { + lines.extend(compact_my_trades_help(narrow_my_trades)); } else { lines.push(help_my_trades_intro()); } - for s in plain_lines { - lines.push(help_shortcut_line(&s)); + if !compact_my_trades { + for s in plain_lines { + lines.push(help_shortcut_line(&s)); + } } lines.push(Line::from(Span::styled( HELP_CLOSE_HINT, @@ -179,6 +191,31 @@ fn help_my_trades_intro() -> Line<'static> { ]) } +fn compact_my_trades_help(narrow: bool) -> Vec> { + if narrow { + let (title_style, _) = settings_instruction_block_style(); + return [ + "↑↓ Enter", + "Tab Shift+I", + "Shift+C Shift+F", + "Shift+R Shift+D", + ] + .into_iter() + .map(|row| Line::from(Span::styled(row, title_style))) + .collect(); + } + + [ + "↑↓ / Enter: Select order / send message", + "Shift+I / Tab: Toggle input / Peer-Solver chat", + "Shift+C / Shift+F: Cancel order / mark fiat sent", + "Shift+R / Shift+D: Release sats / open dispute", + ] + .into_iter() + .map(help_shortcut_line) + .collect() +} + /// Split `Key: description` help strings into bold key + gray body (same as Settings Shift+H rows). fn help_shortcut_line(s: &str) -> Line<'static> { let (title_style, body_style) = settings_instruction_block_style(); @@ -370,6 +407,7 @@ fn help_content(app: &AppState, tab: Tab) -> (String, Vec) { vec![ HELP_MY_TRADES_NAV.to_string(), HELP_MY_TRADES_ENTER_SEND.to_string(), + HELP_MY_TRADES_TAB_CHAT.to_string(), HELP_MY_TRADES_SHIFT_I.to_string(), HELP_MY_TRADES_SHIFT_C_CANCEL.to_string(), HELP_MY_TRADES_SHIFT_F_FIAT_SENT.to_string(), @@ -418,6 +456,19 @@ fn help_content(app: &AppState, tab: Tab) -> (String, Vec) { #[cfg(test)] mod help_content_tests { use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + let mut flat = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + flat.push_str(buf[(x, y)].symbol()); + } + flat.push('\n'); + } + flat.contains(needle) + } #[test] fn my_trades_help_lists_the_dispute_shortcut() { @@ -446,4 +497,61 @@ mod help_content_tests { "Tab focus missing from Observer help: {lines:?}" ); } + + #[test] + fn short_my_trades_help_keeps_essential_shortcuts_and_close_hint_visible() { + let backend = TestBackend::new(80, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let app = AppState::new(UserRole::User); + + terminal + .draw(|f| render_help_popup(f, &app, Tab::User(UserTab::MyTrades))) + .unwrap(); + + let buf = terminal.backend().buffer(); + for expected in [ + "Enter", + "Shift+I", + "Tab", + "Shift+C", + "Shift+F", + "Shift+R", + "Shift+D", + HELP_CLOSE_HINT, + ] { + assert!( + buffer_contains(buf, expected), + "missing {expected:?} from compact My Trades help" + ); + } + } + + #[test] + fn narrow_short_my_trades_help_keeps_shortcuts_and_close_hint_visible() { + let backend = TestBackend::new(20, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let app = AppState::new(UserRole::User); + + terminal + .draw(|f| render_help_popup(f, &app, Tab::User(UserTab::MyTrades))) + .unwrap(); + + let buf = terminal.backend().buffer(); + for expected in [ + "Enter", + "Tab", + "Shift+I", + "Shift+C", + "Shift+F", + "Shift+R", + "Shift+D", + "Esc, Enter or", + "Ctrl+H to close", + ] { + assert!( + buffer_contains(buf, expected), + "missing {expected:?} from narrow compact My Trades help" + ); + } + } } diff --git a/src/ui/helpers/chat_storage.rs b/src/ui/helpers/chat_storage.rs index 9f200a92..1ca96bf8 100644 --- a/src/ui/helpers/chat_storage.rs +++ b/src/ui/helpers/chat_storage.rs @@ -9,6 +9,7 @@ use chrono::DateTime; use nostr_sdk::prelude::EventId; use crate::ui::{ChatParty, ChatSender, DisputeChatMessage, UserChatSender, UserOrderChatMessage}; +use crate::util::chat_utils::clamp_chat_since_cursor_now; use super::attachments::{ legacy_placeholder_matches_filename, message_fields_from_transcript_content, @@ -18,11 +19,13 @@ use super::chat_render::wrap_text_to_lines; const DISPUTES_CHAT_DIR: &str = "disputes_chat"; const ORDERS_CHAT_DIR: &str = "orders_chat"; +const USER_DISPUTES_CHAT_DIR: &str = "user_disputes_chat"; #[derive(Clone, Copy)] enum ChatStorageKind { Disputes, Orders, + UserDisputes, } impl ChatStorageKind { @@ -30,6 +33,7 @@ impl ChatStorageKind { match self { ChatStorageKind::Disputes => DISPUTES_CHAT_DIR, ChatStorageKind::Orders => ORDERS_CHAT_DIR, + ChatStorageKind::UserDisputes => USER_DISPUTES_CHAT_DIR, } } @@ -37,6 +41,7 @@ impl ChatStorageKind { match self { ChatStorageKind::Disputes => "dispute chat", ChatStorageKind::Orders => "order chat", + ChatStorageKind::UserDisputes => "user dispute chat", } } } @@ -249,19 +254,40 @@ pub fn load_chat_from_file(dispute_id: &str) -> Option> /// Returns `true` when the message is durably represented on disk (newly written /// or already present as the last transcript block). Returns `false` on I/O failure. pub fn save_order_chat_message(order_id: &str, message: &UserOrderChatMessage) -> bool { - let file_path = match chat_file_path(ChatStorageKind::Orders, order_id) { + save_user_chat_message_by_kind(ChatStorageKind::Orders, order_id, message) +} + +fn save_user_chat_message_by_kind( + kind: ChatStorageKind, + chat_id: &str, + message: &UserOrderChatMessage, +) -> bool { + let file_path = match chat_file_path(kind, chat_id) { Some(path) => path, None => { - log::warn!("Invalid order chat id format, skipping save: {}", order_id); + log::warn!( + "Invalid {} id format, skipping save: {}", + kind.log_label(), + chat_id + ); return false; } }; let Some(chat_dir) = file_path.parent() else { - log::warn!("Failed to resolve order chat folder for id {}", order_id); + log::warn!( + "Failed to resolve {} folder for id {}", + kind.log_label(), + chat_id + ); return false; }; if let Err(e) = fs::create_dir_all(chat_dir) { - log::warn!("Failed to create order chat folder {:?}: {}", chat_dir, e); + log::warn!( + "Failed to create {} folder {:?}: {}", + kind.log_label(), + chat_dir, + e + ); return false; } @@ -287,7 +313,7 @@ pub fn save_order_chat_message(order_id: &str, message: &UserOrderChatMessage) - } } let formatted_message = format_order_transcript_block(message, &content_block); - append_transcript_block(&file_path, &formatted_message, "order chat") + append_transcript_block(&file_path, &formatted_message, kind.log_label()) } /// Rewrite the full order-chat transcript (used when upgrading a placeholder in place). @@ -295,22 +321,40 @@ pub fn save_order_chat_message(order_id: &str, message: &UserOrderChatMessage) - /// Returns `true` on successful atomic replace; `false` on I/O failure (caller should /// not treat the in-memory upgrade as durable). pub fn rewrite_order_chat_messages(order_id: &str, messages: &[UserOrderChatMessage]) -> bool { - let file_path = match chat_file_path(ChatStorageKind::Orders, order_id) { + rewrite_user_chat_messages_by_kind(ChatStorageKind::Orders, order_id, messages) +} + +fn rewrite_user_chat_messages_by_kind( + kind: ChatStorageKind, + chat_id: &str, + messages: &[UserOrderChatMessage], +) -> bool { + let file_path = match chat_file_path(kind, chat_id) { Some(path) => path, None => { log::warn!( - "Invalid order chat id format, skipping rewrite: {}", - order_id + "Invalid {} id format, skipping rewrite: {}", + kind.log_label(), + chat_id ); return false; } }; let Some(chat_dir) = file_path.parent() else { - log::warn!("Failed to resolve order chat folder for id {}", order_id); + log::warn!( + "Failed to resolve {} folder for id {}", + kind.log_label(), + chat_id + ); return false; }; if let Err(e) = fs::create_dir_all(chat_dir) { - log::warn!("Failed to create order chat folder {:?}: {}", chat_dir, e); + log::warn!( + "Failed to create {} folder {:?}: {}", + kind.log_label(), + chat_dir, + e + ); return false; } let mut body = String::new(); @@ -318,7 +362,7 @@ pub fn rewrite_order_chat_messages(order_id: &str, messages: &[UserOrderChatMess let content_block = transcript_body_for_order_message(message); body.push_str(&format_order_transcript_block(message, &content_block)); } - write_transcript_file(&file_path, &body, "order chat") + write_transcript_file(&file_path, &body, kind.log_label()) } fn format_order_transcript_block(message: &UserOrderChatMessage, content_block: &str) -> String { @@ -403,6 +447,25 @@ pub fn load_order_chat_from_file(order_id: &str) -> Option bool { + save_user_chat_message_by_kind(ChatStorageKind::UserDisputes, order_id, message) +} + +/// Load cached user-to-solver messages for an order. +pub fn load_user_dispute_chat_from_file(order_id: &str) -> Option> { + load_order_chat_from_file_by_kind(ChatStorageKind::UserDisputes, order_id) +} + +/// Max accepted timestamp in the cached user-to-solver transcript. +pub fn user_dispute_chat_since_from_file(order_id: &str) -> Option { + load_user_dispute_chat_from_file(order_id) + .and_then(|msgs| msgs.iter().map(|m| m.timestamp).max()) + .map(clamp_chat_since_cursor_now) +} + /// Max message timestamp from the on-disk order chat transcript (cursor for relay hydrate). /// /// Clamped to local now so a far-future transcript timestamp cannot poison `since`. @@ -548,7 +611,7 @@ fn format_dispute_transcript_block(kind: ChatStorageKind, message: &DisputeChatM (ChatSender::Buyer, _) => "Buyer", (ChatSender::Seller, _) => "Seller", }, - ChatStorageKind::Orders => match message.sender { + ChatStorageKind::Orders | ChatStorageKind::UserDisputes => match message.sender { ChatSender::Admin => "You", ChatSender::Buyer | ChatSender::Seller => "Peer", }, @@ -702,6 +765,30 @@ pub fn remember_order_chat_inner_id(order_id: &str, id: &EventId) -> bool { } } +/// Load durable inner event ids for a user-to-solver dispute chat. +pub fn load_user_dispute_chat_inner_ids(order_id: &str) -> HashSet { + match inner_ids_file_path(ChatStorageKind::UserDisputes, order_id, None) { + Some(path) => with_inner_id_set(&path, |set| set.clone()), + None => HashSet::new(), + } +} + +/// Returns `true` if this inner id was already accepted for the solver chat. +pub fn user_dispute_chat_inner_id_known(order_id: &str, id: &EventId) -> bool { + match inner_ids_file_path(ChatStorageKind::UserDisputes, order_id, None) { + Some(path) => inner_id_known_at_path(&path, id), + None => false, + } +} + +/// Persist an accepted solver-chat inner id after its transcript is durable. +pub fn remember_user_dispute_chat_inner_id(order_id: &str, id: &EventId) -> bool { + match inner_ids_file_path(ChatStorageKind::UserDisputes, order_id, None) { + Some(path) => remember_inner_id_at_path(&path, id), + None => true, + } +} + /// Load durable inner event ids for one admin↔party dispute channel. /// /// Reads through a process-wide per-path cache (first touch loads the `.inner_ids` file). diff --git a/src/ui/helpers/mod.rs b/src/ui/helpers/mod.rs index 2f52da0d..14bd3479 100644 --- a/src/ui/helpers/mod.rs +++ b/src/ui/helpers/mod.rs @@ -28,6 +28,11 @@ pub use chat_storage::{ remember_order_chat_inner_id, rewrite_dispute_chat_messages, rewrite_order_chat_messages, save_chat_message, save_order_chat_message, }; +pub use chat_storage::{ + load_user_dispute_chat_from_file, load_user_dispute_chat_inner_ids, + remember_user_dispute_chat_inner_id, save_user_dispute_chat_message, + user_dispute_chat_inner_id_known, user_dispute_chat_since_from_file, +}; pub use chat_visibility::{ count_order_attachments, count_visible_attachments, get_order_attachment_messages, get_selected_chat_message, get_visible_attachment_messages, message_visible_for_party, diff --git a/src/ui/helpers/order_chat_projection.rs b/src/ui/helpers/order_chat_projection.rs index fb895947..009f6ae2 100644 --- a/src/ui/helpers/order_chat_projection.rs +++ b/src/ui/helpers/order_chat_projection.rs @@ -2,7 +2,8 @@ use std::cmp::Ordering; use std::collections::HashMap; use std::str::FromStr; -use mostro_core::prelude::{Payload, Peer, SmallOrder, Status, UserInfo}; +use mostro_core::prelude::{Action, Payload, Peer, SmallOrder, Status, UserInfo}; +use uuid::Uuid; use crate::models::Order; use crate::ui::{AppState, OrderMessage}; @@ -24,6 +25,10 @@ pub struct OrderChatListItem { /// Reputation for the buyer/seller trade pubkey when the daemon sent `Payload::Peer` with matching pubkey. pub buyer_reputation: Option, pub seller_reputation: Option, + /// Solver pubkey announced by `AdminTookDispute`. + pub solver_pubkey: Option, + /// Dispute UUID announced by Mostro for this order. + pub dispute_id: Option, } /// Maker listings back on the book (`pending`) with no active trade-DM row in Messages. @@ -52,6 +57,8 @@ pub fn order_chat_list_item_from_db_order(order: &Order) -> Option merge_order_fields(entry, order, msg), - Payload::Peer(peer) => merge_peer_fields(entry, peer), + Payload::Peer(peer) => { + if msg.message.get_inner_message_kind().action == Action::AdminTookDispute { + entry.solver_pubkey = Some(peer.pubkey.clone()); + } else { + merge_peer_fields(entry, peer); + } + } + Payload::Dispute(dispute_id, _) => { + entry.dispute_id = Some(dispute_id.to_string()); + } _ => {} } } @@ -135,6 +151,8 @@ fn build_order_chat_list_from_messages(messages: &[OrderMessage]) -> Vec Vec match app.messages.lock() { Ok(guard) => { let messages = guard.clone(); - build_active_order_chat_list(&messages, &app.my_trades_maker_book) + let mut rows = build_active_order_chat_list(&messages, &app.my_trades_maker_book); + for row in &mut rows { + let Some(header) = Uuid::parse_str(&row.order_id) + .ok() + .and_then(|id| app.order_chat_static.get(&id)) + else { + continue; + }; + row.solver_pubkey = row + .solver_pubkey + .clone() + .or_else(|| header.solver_pubkey.clone()); + row.dispute_id = row.dispute_id.clone().or_else(|| header.dispute_id.clone()); + } + rows } Err(e) => { fatal_on_poisoned_messages_lock(e); @@ -206,3 +238,88 @@ pub fn active_order_chat_list_snapshot(app: &AppState) -> Vec } } } + +#[cfg(test)] +mod tests { + use super::{active_order_chat_list_snapshot, build_active_order_chat_list}; + use crate::ui::{AppState, OrderChatStaticHeader, OrderMessage, UserRole}; + use mostro_core::prelude::{Action, Kind, Message, Payload, Status}; + use nostr_sdk::prelude::Keys; + use uuid::Uuid; + + #[test] + fn dispute_payload_populates_dispute_id() { + let order_id = Uuid::new_v4(); + let dispute_id = Uuid::new_v4(); + let message = OrderMessage { + message: Message::new_dispute( + Some(order_id), + None, + Some(1), + Action::DisputeInitiatedByYou, + Some(Payload::Dispute(dispute_id, None)), + ), + timestamp: 1, + sender: Keys::generate().public_key(), + order_id: Some(order_id), + trade_index: 1, + sat_amount: None, + buyer_invoice: None, + order_kind: None, + is_mine: Some(false), + order_status: Some(Status::Dispute), + order_snapshot: None, + read: true, + auto_popup_shown: true, + }; + + let rows = build_active_order_chat_list(&[message], &[]); + + assert_eq!( + rows[0].dispute_id.as_deref(), + Some(dispute_id.to_string().as_str()) + ); + } + + #[test] + fn static_dispute_metadata_survives_replaced_order_message() { + let order_id = Uuid::new_v4(); + let mut app = AppState::new(UserRole::User); + app.order_chat_static.insert( + order_id, + OrderChatStaticHeader { + order_id, + kind: Some(Kind::Buy), + created_at: None, + trade_index: 1, + initiator_trade_pubkey: "initiator".to_string(), + is_mine: false, + solver_pubkey: Some("solver-pubkey".to_string()), + dispute_id: Some("dispute-id".to_string()), + }, + ); + app.messages + .lock() + .expect("messages lock") + .push(OrderMessage { + message: Message::new_order(Some(order_id), None, Some(1), Action::FiatSent, None), + timestamp: 2, + sender: Keys::generate().public_key(), + order_id: Some(order_id), + trade_index: 1, + sat_amount: None, + buyer_invoice: None, + order_kind: Some(Kind::Buy), + is_mine: Some(false), + order_status: Some(Status::Dispute), + order_snapshot: None, + read: true, + auto_popup_shown: true, + }); + + let rows = active_order_chat_list_snapshot(&app); + + assert_eq!(rows[0].solver_pubkey.as_deref(), Some("solver-pubkey")); + assert_eq!(rows[0].dispute_id.as_deref(), Some("dispute-id")); + } +} diff --git a/src/ui/helpers/startup.rs b/src/ui/helpers/startup.rs index fa7f4d41..f02f5def 100644 --- a/src/ui/helpers/startup.rs +++ b/src/ui/helpers/startup.rs @@ -12,10 +12,11 @@ use super::order_chat_projection::order_chat_list_item_from_db_order; use crate::models::{AdminDispute, Order, User}; use crate::ui::{ AdminChatLastSeen, AdminChatUpdate, AppState, ChatParty, DisputeChatMessage, OrderChatLastSeen, - OrderChatStaticHeader, OrderMessage, UserChatSender, UserOrderChatMessage, UserRole, + OrderChatStaticHeader, OrderMessage, UserChatChannel, UserChatSender, UserOrderChatMessage, + UserRole, }; use crate::util::{ - chat_listener::{track_dispute_chat, track_order_chat}, + chat_listener::{track_dispute_chat, track_order_chat, track_user_dispute_chat}, chat_utils::{ clamp_chat_since_cursor_now, derive_shared_key_hex, dispute_chat_allowed_signers, dispute_chat_role_for_inner_signer, order_chat_allowed_signers, parse_chat_pubkey, @@ -28,9 +29,12 @@ use super::attachments::{ }; use super::chat_storage::{ dispute_chat_inner_id_known, load_chat_from_file, load_order_chat_from_file, - max_party_timestamps, order_chat_inner_id_known, remember_dispute_chat_inner_id, - remember_order_chat_inner_id, rewrite_dispute_chat_messages, rewrite_order_chat_messages, - save_chat_message, save_order_chat_message, + load_user_dispute_chat_from_file, max_party_timestamps, order_chat_inner_id_known, + remember_dispute_chat_inner_id, remember_order_chat_inner_id, + remember_user_dispute_chat_inner_id, rewrite_dispute_chat_messages, + rewrite_order_chat_messages, save_chat_message, save_order_chat_message, + save_user_dispute_chat_message, user_dispute_chat_inner_id_known, + user_dispute_chat_since_from_file, }; /// Parse `admin_privkey` text and store in [`AppState::admin_keys`]. @@ -173,31 +177,46 @@ pub async fn track_startup_chats(pool: &SqlitePool, app: &AppState) { let shared_hex = order.order_chat_shared_key_hex.clone().or_else(|| { derive_shared_key_hex(Some(&trade_keys), order.counterparty_pubkey.as_deref()) }); - let Some(shared_hex) = shared_hex else { - continue; - }; - let Some(allowed) = order_chat_allowed_signers( - trade_keys.public_key(), - order.counterparty_pubkey.as_deref(), - ) else { - log::warn!( - "startup: order {} missing counterparty pubkey; not tracking chat", - row.id + if let Some(shared_hex) = shared_hex { + if let Some(allowed) = order_chat_allowed_signers( + trade_keys.public_key(), + order.counterparty_pubkey.as_deref(), + ) { + let since = app + .order_chat_last_seen + .get(&row.id) + .and_then(|s| s.last_seen_timestamp) + .map(clamp_chat_since_cursor_now); + track_order_chat( + row.id.clone(), + shared_hex, + trade_keys.public_key(), + allowed, + since, + ); + } else { + log::warn!( + "startup: order {} missing counterparty pubkey; not tracking chat", + row.id + ); + } + } + + if let (Some(shared_hex), Some(solver)) = ( + order.dispute_chat_shared_key_hex.clone(), + order + .solver_pubkey + .as_deref() + .and_then(|value| PublicKey::parse(value).ok()), + ) { + track_user_dispute_chat( + row.id.clone(), + shared_hex, + trade_keys.public_key(), + solver, + user_dispute_chat_since_from_file(&row.id), ); - continue; - }; - let since = app - .order_chat_last_seen - .get(&row.id) - .and_then(|s| s.last_seen_timestamp) - .map(clamp_chat_since_cursor_now); - track_order_chat( - row.id.clone(), - shared_hex, - trade_keys.public_key(), - allowed, - since, - ); + } } } UserRole::Admin => { @@ -277,6 +296,16 @@ pub async fn load_user_order_chats_at_startup(pool: &SqlitePool, app: &mut AppSt }, ); } + if let Some(messages) = load_user_dispute_chat_from_file(&order_id) { + let max_ts = messages.iter().map(|m| m.timestamp).max().unwrap_or(0); + app.user_dispute_chats.insert(order_id.clone(), messages); + app.user_dispute_chat_last_seen.insert( + order_id, + OrderChatLastSeen { + last_seen_timestamp: Some(clamp_chat_since_cursor_now(max_ts)), + }, + ); + } } refresh_my_trades_maker_book_cache(pool, app).await; @@ -393,6 +422,8 @@ fn order_chat_static_from_db_order(row: &Order) -> Option trade_index, initiator_trade_pubkey: trade_keys.public_key().to_string(), is_mine: row.is_mine, + solver_pubkey: row.solver_pubkey.clone(), + dispute_id: row.dispute_id.clone(), }) } @@ -450,9 +481,15 @@ pub async fn sync_user_order_history_messages_from_db(pool: &SqlitePool, app: &m pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec) { for update in updates { let order_id = update.order_id.clone(); - let messages_vec = app.order_chats.entry(order_id.clone()).or_default(); - let mut max_ts = app - .order_chat_last_seen + let messages_vec = match update.channel { + UserChatChannel::Peer => app.order_chats.entry(order_id.clone()).or_default(), + UserChatChannel::Solver => app.user_dispute_chats.entry(order_id.clone()).or_default(), + }; + let last_seen_map = match update.channel { + UserChatChannel::Peer => &mut app.order_chat_last_seen, + UserChatChannel::Solver => &mut app.user_dispute_chat_last_seen, + }; + let mut max_ts = last_seen_map .get(&order_id) .and_then(|s| s.last_seen_timestamp) .unwrap_or(0); @@ -471,16 +508,23 @@ pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec order_chat_inner_id_known(&order_id, &inner_id), + UserChatChannel::Solver => user_dispute_chat_inner_id_known(&order_id, &inner_id), + }; + if inner_id_known { if ts > max_ts { max_ts = ts; } continue; } - let (msg_content, attachment) = match try_parse_attachment_message(&content) { - Some((attachment, display)) => (display, Some(attachment)), - None => (content.clone(), None), + let (msg_content, attachment) = match update.channel { + UserChatChannel::Peer => match try_parse_attachment_message(&content) { + Some((attachment, display)) => (display, Some(attachment)), + None => (content.clone(), None), + }, + UserChatChannel::Solver => (content.clone(), None), }; if let Some(ref att) = attachment { @@ -536,7 +580,14 @@ pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec { + let _ = remember_order_chat_inner_id(&order_id, &inner_id); + } + UserChatChannel::Solver => { + let _ = remember_user_dispute_chat_inner_id(&order_id, &inner_id); + } + } if ts > max_ts { max_ts = ts; } @@ -553,19 +604,31 @@ pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec save_order_chat_message(&order_id, &msg), + UserChatChannel::Solver => save_user_dispute_chat_message(&order_id, &msg), + }; + if !saved { log::warn!( - "Failed to persist order chat message for {order_id}; leaving inner id unrecorded" + "Failed to persist {} chat message for {order_id}; leaving inner id unrecorded", + update.channel ); continue; } - let _ = remember_order_chat_inner_id(&order_id, &inner_id); + match update.channel { + UserChatChannel::Peer => { + let _ = remember_order_chat_inner_id(&order_id, &inner_id); + } + UserChatChannel::Solver => { + let _ = remember_user_dispute_chat_inner_id(&order_id, &inner_id); + } + } messages_vec.push(msg); if ts > max_ts { max_ts = ts; } } - app.order_chat_last_seen.insert( + last_seen_map.insert( order_id, OrderChatLastSeen { last_seen_timestamp: Some(clamp_chat_since_cursor_now(max_ts)), diff --git a/src/ui/key_handler/chat_helpers.rs b/src/ui/key_handler/chat_helpers.rs index 7ba8a9ff..a4a44a70 100644 --- a/src/ui/key_handler/chat_helpers.rs +++ b/src/ui/key_handler/chat_helpers.rs @@ -1,7 +1,8 @@ use crate::ui::helpers::active_order_chat_list_snapshot; use crate::ui::{ - helpers::message_visible_for_party, AdminMode, AppState, ChatParty, MessageViewState, - OperationResult, RatingOrderState, UiMode, ViewingMessageButtonSelection, + helpers::message_visible_for_party, AdminMode, AppState, ChatParty, DisputeChatMessage, + MessageViewState, OperationResult, RatingOrderState, UiMode, UserChatChannel, + ViewingMessageButtonSelection, }; use mostro_core::prelude::{Action, Status}; use tokio::sync::mpsc::UnboundedSender; @@ -12,7 +13,7 @@ use uuid::Uuid; /// Uses the same visibility logic as the chat scrollview and get_selected_chat_message /// so that selection index and visible list stay in sync. pub fn get_visible_message_count( - messages: &[crate::ui::DisputeChatMessage], + messages: &[DisputeChatMessage], active_chat_party: ChatParty, ) -> usize { messages @@ -143,10 +144,15 @@ pub fn jump_to_order_chat_bottom(app: &mut AppState) -> bool { } /// After sending a local message, scroll to the latest line and update the tracker. -pub fn scroll_order_chat_after_send(app: &mut AppState, order_id: &str) { +pub fn scroll_order_chat_after_send(app: &mut AppState, order_id: &str, channel: UserChatChannel) { app.order_chat_scrollview_state.scroll_to_bottom(); - let count = app.order_chats.get(order_id).map(|m| m.len()).unwrap_or(0); - app.order_chat_scroll_tracker = Some((order_id.to_string(), count)); + let count = match channel { + UserChatChannel::Peer => app.order_chats.get(order_id), + UserChatChannel::Solver => app.user_dispute_chats.get(order_id), + } + .map(|m| m.len()) + .unwrap_or(0); + app.order_chat_scroll_tracker = Some((order_id.to_string(), channel, count)); } /// Resolve the currently selected order id for the MyTrades (Order Chat) tab. diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 99e347bc..4e2bfe25 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -20,7 +20,8 @@ use crate::ui::orders::{ use crate::ui::{ order_message_to_notification, AdminMode, AdminTab, AppState, ChatParty, InvoiceInputState, InvoiceNotificationActionSelection, MessageViewState, OperationResult, RatingOrderState, Tab, - TakeOrderState, ThreeState, UiMode, UserMode, UserRole, UserTab, ViewingMessageButtonSelection, + TakeOrderState, ThreeState, UiMode, UserChatChannel, UserChatSender, UserMode, + UserOrderChatMessage, UserRole, UserTab, ViewingMessageButtonSelection, }; // User handlers moved to user_handlers.rs use crate::ui::key_handler::async_tasks::{ @@ -59,7 +60,10 @@ use crate::ui::key_handler::validation::{ normalize_mostro_pubkey, validate_currency, validate_relay, }; use crate::ui::tabs::settings_tab::{settings_action_for_index, SettingsMenuAction}; -use crate::util::chat_utils::{fetch_observer_chat, observer_known_signer_roles}; +use crate::util::chat_utils::{ + derive_shared_keys, fetch_observer_chat, keys_from_shared_hex, observer_known_signer_roles, + send_user_order_chat_message_via_shared_key, +}; use crate::util::dm_utils::{apply_saved_ln_address_invoice_choice, present_add_invoice_popup}; use crate::util::order_utils::BondSlashChoice; @@ -100,6 +104,7 @@ struct DisputeChatTarget { #[derive(Clone)] struct OrderChatTarget { order_id: String, + channel: UserChatChannel, } struct EnterChatSendConfig { @@ -118,7 +123,7 @@ fn run_enter_chat_send_flow Option, - ApplyLocal: FnOnce(&mut AppState, &T, &str), + ApplyLocal: FnOnce(&mut AppState, &T, &str) -> bool, SpawnRemote: FnOnce(T, String), ResetInput: FnOnce(&mut AppState), { @@ -137,7 +142,9 @@ fn run_enter_chat_send_flow Option .get(app.selected_order_chat_idx) .map(|row| OrderChatTarget { order_id: row.order_id.clone(), + channel: app.active_user_chat_channel, }) } +fn persist_local_user_chat_message( + app: &mut AppState, + target: &OrderChatTarget, + local_msg: UserOrderChatMessage, +) -> bool { + let persisted = match target.channel { + UserChatChannel::Peer => save_order_chat_message(&target.order_id, &local_msg), + UserChatChannel::Solver => save_user_dispute_chat_message(&target.order_id, &local_msg), + }; + if !persisted { + app.mode = UiMode::operation_result(OperationResult::Error(format!( + "Failed to save {channel} chat message locally. The message was not sent.", + channel = target.channel + ))); + return false; + } + + match target.channel { + UserChatChannel::Peer => { + app.order_chats + .entry(target.order_id.clone()) + .or_default() + .push(local_msg); + } + UserChatChannel::Solver => { + app.user_dispute_chats + .entry(target.order_id.clone()) + .or_default() + .push(local_msg); + } + } + scroll_order_chat_after_send(app, &target.order_id, target.channel); + true +} + fn spawn_user_order_chat_send_task( ctx: &super::EnterKeyContext<'_>, order_id: String, + channel: UserChatChannel, content: String, ) { let client = ctx.client.clone(); let pool = ctx.pool.clone(); let mostro_info = ctx.mostro_info.clone(); tokio::spawn(async move { - let order = match crate::models::Order::get_by_id(&pool, &order_id).await { + let order = match Order::get_by_id(&pool, &order_id).await { Ok(o) => o, Err(e) => { log::warn!("order chat send skipped (order not found): {}", e); @@ -186,19 +230,30 @@ fn spawn_user_order_chat_send_task( None => return, }; let trade_keys = Keys::new(trade_sk); - let shared_keys = order - .order_chat_shared_key_hex - .as_deref() - .and_then(crate::util::chat_utils::keys_from_shared_hex) - .or_else(|| { - let cp = order.counterparty_pubkey.as_deref()?; - let pk = PublicKey::parse(cp).ok()?; - crate::util::chat_utils::derive_shared_keys(Some(&trade_keys), Some(&pk)) - }); + let shared_keys = match channel { + UserChatChannel::Peer => order + .order_chat_shared_key_hex + .as_deref() + .and_then(keys_from_shared_hex) + .or_else(|| { + let cp = order.counterparty_pubkey.as_deref()?; + let pk = PublicKey::parse(cp).ok()?; + derive_shared_keys(Some(&trade_keys), Some(&pk)) + }), + UserChatChannel::Solver => order + .dispute_chat_shared_key_hex + .as_deref() + .and_then(keys_from_shared_hex) + .or_else(|| { + let solver = order.solver_pubkey.as_deref()?; + let pk = PublicKey::parse(solver).ok()?; + derive_shared_keys(Some(&trade_keys), Some(&pk)) + }), + }; let Some(shared_keys) = shared_keys else { return; }; - if let Err(e) = crate::util::chat_utils::send_user_order_chat_message_via_shared_key( + if let Err(e) = send_user_order_chat_message_via_shared_key( &client, &trade_keys, &shared_keys, @@ -207,7 +262,7 @@ fn spawn_user_order_chat_send_task( ) .await { - log::warn!("Failed to send user order chat: {}", e); + log::warn!("Failed to send user {channel} chat: {e}"); } }); } @@ -317,6 +372,7 @@ fn handle_enter_admin_managing_dispute_chat(app: &mut AppState, ctx: &super::Ent |app, target, content| { prepare_admin_chat_message(&target.dispute_id_key, content, app); message_counter(app, &target.dispute_id_key); + true }, |target, content| { send_admin_chat_message_via_shared_key( @@ -348,21 +404,16 @@ fn handle_enter_user_order_chat(app: &mut AppState, ctx: &super::EnterKeyContext }, |app| resolve_selected_order_chat_target(app), |app, target, content| { - let local_msg = crate::ui::UserOrderChatMessage { - sender: crate::ui::UserChatSender::You, + let local_msg = UserOrderChatMessage { + sender: UserChatSender::You, content: content.to_string(), timestamp: chrono::Utc::now().timestamp(), attachment: None, }; - app.order_chats - .entry(target.order_id.clone()) - .or_default() - .push(local_msg.clone()); - save_order_chat_message(&target.order_id, &local_msg); - scroll_order_chat_after_send(app, &target.order_id); + persist_local_user_chat_message(app, target, local_msg) }, |target, content| { - spawn_user_order_chat_send_task(ctx, target.order_id, content); + spawn_user_order_chat_send_task(ctx, target.order_id, target.channel, content); }, |app| { app.order_chat_input.clear(); @@ -1256,3 +1307,72 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> }; } } + +#[cfg(test)] +mod tests { + use super::{ + persist_local_user_chat_message, run_enter_chat_send_flow, EnterChatSendConfig, + OrderChatTarget, + }; + use crate::ui::{ + AppState, OperationResult, UiMode, UserChatChannel, UserChatSender, UserOrderChatMessage, + UserRole, + }; + use std::cell::Cell; + + #[test] + fn failed_user_chat_persistence_keeps_input_and_aborts_send() { + for channel in [UserChatChannel::Peer, UserChatChannel::Solver] { + let mut app = AppState::new(UserRole::User); + app.order_chat_input = "retry me".to_string(); + let remote_spawned = Cell::new(false); + let input_reset = Cell::new(false); + let mode_after_send = app.mode.clone(); + + run_enter_chat_send_flow( + &mut app, + EnterChatSendConfig { + mode_after_send, + input_enabled: true, + content: "retry me".to_string(), + }, + |_| { + Some(OrderChatTarget { + order_id: "invalid-order-id".to_string(), + channel, + }) + }, + |app, target, content| { + persist_local_user_chat_message( + app, + target, + UserOrderChatMessage { + sender: UserChatSender::You, + content: content.to_string(), + timestamp: 1, + attachment: None, + }, + ) + }, + |_, _| remote_spawned.set(true), + |app| { + input_reset.set(true); + app.order_chat_input.clear(); + }, + ); + + assert_eq!(app.order_chat_input, "retry me"); + assert!(!input_reset.get()); + assert!(!remote_spawned.get()); + assert!(app.order_chats.is_empty()); + assert!(app.user_dispute_chats.is_empty()); + let UiMode::OperationResult(result) = &app.mode else { + panic!("storage failure should show an operation error"); + }; + assert!(matches!( + result.as_ref(), + OperationResult::Error(message) if message.contains("message was not sent") + )); + } + } +} diff --git a/src/ui/key_handler/message_handlers.rs b/src/ui/key_handler/message_handlers.rs index c73b153c..25fcb71d 100644 --- a/src/ui/key_handler/message_handlers.rs +++ b/src/ui/key_handler/message_handlers.rs @@ -1,3 +1,4 @@ +use crate::models::Order; use crate::ui::key_handler::EnterKeyContext; use crate::ui::OperationResult; use crate::ui::{ @@ -204,6 +205,15 @@ fn spawn_dispute(app: &mut AppState, ctx: &EnterKeyContext<'_>, order_id: Uuid) { log::warn!("Failed to save Dispute status for order {order_id}: {e}"); } + if let Err(e) = Order::update_dispute_id( + &pool_clone, + &order_id.to_string(), + &dispute_id.to_string(), + ) + .await + { + log::warn!("Failed to save dispute id for order {order_id}: {e}"); + } let _ = result_tx.send(OperationResult::Info(format!( "Dispute opened. Dispute id: {dispute_id} — give it to the solver." ))); diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 4b5ca186..f3a7e280 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -27,7 +27,8 @@ use crate::ui::{ }, AdminMode, AdminTab, AppState, ChatAttachment, ChatSender, DisputeFilter, InvoiceNotificationActionSelection, LnAddressVerifyResult, MostroInfoFetchResult, - OperationResult, Tab, TakeOrderState, UiMode, UserMode, UserTab, ViewingMessageButtonSelection, + OperationResult, Tab, TakeOrderState, UiMode, UserChatChannel, UserMode, UserTab, + ViewingMessageButtonSelection, }; use crate::util::{MostroInstanceInfo, OrderDmSubscriptionCmd, SendOrderAttachmentJob}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; @@ -888,7 +889,9 @@ pub fn handle_key_event( } } if let Tab::User(UserTab::MyTrades) = app.active_tab { - if app.mode.user_my_trades_interactive() { + if app.mode.user_my_trades_interactive() + && app.active_user_chat_channel == UserChatChannel::Peer + { if let Some(row) = active_order_chat_list_snapshot(app).get(app.selected_order_chat_idx) { @@ -907,7 +910,9 @@ pub fn handle_key_event( && matches!(code, KeyCode::Char('o') | KeyCode::Char('O')) { if let Tab::User(UserTab::MyTrades) = app.active_tab { - if app.mode.user_my_trades_interactive() { + if app.mode.user_my_trades_interactive() + && app.active_user_chat_channel == UserChatChannel::Peer + { if let Some(row) = active_order_chat_list_snapshot(app).get(app.selected_order_chat_idx) { diff --git a/src/ui/key_handler/navigation.rs b/src/ui/key_handler/navigation.rs index da34ce9d..4c204596 100644 --- a/src/ui/key_handler/navigation.rs +++ b/src/ui/key_handler/navigation.rs @@ -4,8 +4,8 @@ use crate::ui::helpers::{ }; use crate::ui::orders::strip_new_order_messages_and_clamp_selected; use crate::ui::{ - AdminMode, AdminTab, AppState, FormState, Tab, UiMode, UserMode, UserRole, UserTab, - ViewingMessageButtonSelection, + AdminMode, AdminTab, AppState, FormState, Tab, UiMode, UserChatChannel, UserMode, UserRole, + UserTab, ViewingMessageButtonSelection, }; use crossterm::event::KeyCode; use mostro_core::prelude::*; diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 83e7398a..858e1522 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -24,6 +24,10 @@ pub struct OrderChatStaticHeader { pub initiator_trade_pubkey: String, /// `true` = we are maker, `false` = taker. pub is_mine: bool, + /// Assigned solver trade pubkey, when available. + pub solver_pubkey: Option, + /// Dispute UUID persisted for this order, when available. + pub dispute_id: Option, } #[derive(Clone, Debug, Default)] @@ -378,6 +382,10 @@ pub struct MessageNotification { pub body: Option, /// Maker bond (Phase 5): pay before the order is published to the book. pub maker_bond_publish: bool, + /// Solver announced by an `AdminTookDispute` DM, when present. + pub solver_pubkey: Option, + /// Dispute UUID announced by a dispute DM, when present. + pub dispute_id: Option, } /// Whether an invoice modal is appropriate for the current trade phase. @@ -671,6 +679,7 @@ pub fn order_message_to_notification(msg: &OrderMessage) -> MessageNotification Action::AdminCanceled => "Order canceled by admin", Action::Dispute | Action::DisputeInitiatedByYou => "Dispute", Action::DisputeInitiatedByPeer => "Your counterpart opened a dispute", + Action::AdminTookDispute => "A solver joined the dispute", Action::Rate => "Rate Counterparty", Action::RateReceived | Action::PurchaseCompleted => "Rate Counterparty completed", Action::Release | Action::Released => "Release", @@ -687,6 +696,14 @@ pub fn order_message_to_notification(msg: &OrderMessage) -> MessageNotification } else { None }; + let solver_pubkey = match (&action, inner_message_kind.payload.as_ref()) { + (Action::AdminTookDispute, Some(Payload::Peer(peer))) => Some(peer.pubkey.clone()), + _ => None, + }; + let dispute_id = match inner_message_kind.payload.as_ref() { + Some(Payload::Dispute(dispute_id, _)) => Some(dispute_id.to_string()), + _ => None, + }; MessageNotification { order_id: msg.order_id, @@ -697,6 +714,8 @@ pub fn order_message_to_notification(msg: &OrderMessage) -> MessageNotification invoice: msg.buyer_invoice.clone(), body, maker_bond_publish: msg.order_status == Some(Status::WaitingMakerBond), + solver_pubkey, + dispute_id, } } @@ -722,6 +741,8 @@ pub fn message_action_compact_label(action: &Action) -> &'static str { Action::Release | Action::Released => "Release sats", Action::Dispute | Action::DisputeInitiatedByYou => "Dispute", Action::DisputeInitiatedByPeer => "Dispute by Peer", + Action::AdminTookDispute => "Solver Joined Dispute", + Action::CantDo => "Action Rejected", Action::Canceled => "Canceled", Action::AdminCanceled => "Admin Canceled", Action::Rate => "Rate Counterparty", @@ -744,6 +765,7 @@ pub fn message_action_compact_label_for_message(msg: &OrderMessage) -> &'static Some(Status::Canceled) => "Canceled", Some(Status::CanceledByAdmin) => "Admin Canceled", Some(Status::CooperativelyCanceled) => "Cooperatively Canceled", + Some(Status::Dispute) => "Trade in Dispute", Some(Status::WaitingBuyerInvoice) => "Waiting Buyer Invoice", Some(Status::WaitingPayment) => "Waiting Seller Payment", Some(Status::Expired) => "Expired", @@ -1463,6 +1485,67 @@ mod message_emoji_and_badge_tests { ); } + #[test] + fn solver_assignment_has_a_readable_label() { + assert_eq!( + message_action_compact_label(&Action::AdminTookDispute), + "Solver Joined Dispute" + ); + } + + #[test] + fn cant_do_has_a_readable_label() { + assert_eq!( + message_action_compact_label(&Action::CantDo), + "Action Rejected" + ); + } + + #[test] + fn notifications_carry_dispute_metadata() { + let order_id = uuid::Uuid::new_v4(); + let dispute_id = uuid::Uuid::new_v4(); + let mut solver_msg = sample_msg(Action::AdminTookDispute, None, None, None); + solver_msg.order_id = Some(order_id); + solver_msg.message = Message::new_order( + Some(order_id), + None, + None, + Action::AdminTookDispute, + Some(Payload::Peer(Peer::new("solver-pubkey".to_string(), None))), + ); + let solver_notification = order_message_to_notification(&solver_msg); + assert_eq!( + solver_notification.solver_pubkey.as_deref(), + Some("solver-pubkey") + ); + + let mut dispute_msg = sample_msg(Action::DisputeInitiatedByYou, None, None, None); + dispute_msg.order_id = Some(order_id); + dispute_msg.message = Message::new_dispute( + Some(order_id), + None, + None, + Action::DisputeInitiatedByYou, + Some(Payload::Dispute(dispute_id, None)), + ); + let dispute_notification = order_message_to_notification(&dispute_msg); + assert_eq!( + dispute_notification.dispute_id.as_deref(), + Some(dispute_id.to_string().as_str()) + ); + } + + #[test] + fn dispute_status_does_not_fall_back_to_unknown_action() { + let msg = sample_msg(Action::Orders, None, None, Some(Status::Dispute)); + + assert_eq!( + message_action_compact_label_for_message(&msg), + "Trade in Dispute" + ); + } + #[test] fn peer_initiated_dispute_warns_in_the_timeline() { assert_eq!( @@ -1726,6 +1809,8 @@ mod order_success_placeholder_tests { trade_index: 1, initiator_trade_pubkey: keys.public_key().to_string(), is_mine, + solver_pubkey: None, + dispute_id: None, }), } } @@ -2104,6 +2189,8 @@ mod placeholder_action_tests { trade_index: 2, initiator_trade_pubkey: keys.public_key().to_string(), is_mine: true, + solver_pubkey: None, + dispute_id: None, }), }; let msg = try_placeholder_order_message_from_success(&os).expect("placeholder"); diff --git a/src/ui/state.rs b/src/ui/state.rs index ed617378..56ea703b 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -4,7 +4,7 @@ pub use crate::ui::app_state::{AppState, ObserverInputField, UiMode}; pub use crate::ui::chat::{ AdminChatLastSeen, AdminChatUpdate, ChatAttachment, ChatAttachmentType, ChatParty, ChatSender, DecodedChatMessage, DisputeChatMessage, DisputeFilter, OrderChatLastSeen, OrderChatUpdate, - UserChatSender, UserOrderChatMessage, + UserChatChannel, UserChatSender, UserOrderChatMessage, }; pub use crate::ui::navigation::{AdminTab, Tab, UserRole, UserTab}; pub use crate::ui::orders::{ diff --git a/src/ui/tabs/order_in_progress_tab.rs b/src/ui/tabs/order_in_progress_tab.rs index 57dbe666..0a644ac2 100644 --- a/src/ui/tabs/order_in_progress_tab.rs +++ b/src/ui/tabs/order_in_progress_tab.rs @@ -21,7 +21,7 @@ use crate::ui::helpers::{ format_user_rating, }; use crate::ui::UserOrderChatMessage; -use crate::ui::{AppState, UserChatSender}; +use crate::ui::{AppState, UserChatChannel, UserChatSender}; use crate::ui::{BACKGROUND_COLOR, PRIMARY_COLOR}; /// `Order ID: …` for the sidebar — same style as disputes; shows the full id when it fits the column. @@ -45,6 +45,7 @@ fn sidebar_order_list_label(order_id: &str, inner_width: u16) -> String { fn build_order_chat_content( messages: &[UserOrderChatMessage], content_width: u16, + channel: UserChatChannel, ) -> (Vec>, u16, Vec) { fn wrap_text_to_lines(content: &str, max_width: u16) -> Vec { if max_width == 0 { @@ -104,7 +105,10 @@ fn build_order_chat_content( let sender = msg.sender; let label = match sender { UserChatSender::You => "You", - UserChatSender::Peer => "Peer", + UserChatSender::Peer => match channel { + UserChatChannel::Peer => "Peer", + UserChatChannel::Solver => "Solver", + }, }; let color = match sender { UserChatSender::You => Color::Cyan, @@ -246,6 +250,14 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap let static_h = Uuid::parse_str(&selected.order_id) .ok() .and_then(|id| app.order_chat_static.get(&id)); + let solver_available = selected.solver_pubkey.is_some() + || static_h + .and_then(|header| header.solver_pubkey.as_ref()) + .is_some(); + if !solver_available { + app.active_user_chat_channel = UserChatChannel::Peer; + } + let active_channel = app.active_user_chat_channel; let order_kind = static_h .and_then(|h| h.kind.map(|k| k.to_string())) .unwrap_or_else(|| "Unknown".to_string()); @@ -292,6 +304,32 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap let order_id_display = static_h .map(|h| h.order_id.to_string()) .unwrap_or_else(|| selected.order_id.clone()); + let dispute_id = selected + .dispute_id + .as_deref() + .or_else(|| static_h.and_then(|header| header.dispute_id.as_deref())); + let context_line = if let Some(dispute_id) = dispute_id { + Line::from(vec![ + Span::styled("Dispute ID: ", Style::default().fg(Color::Gray)), + Span::styled( + dispute_id.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(vec![ + Span::styled( + format!("Initiator: {initiator_role} "), + Style::default().fg(Color::Gray), + ), + Span::styled(initiator_pubkey_display, Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled("Created: ", Style::default().fg(Color::Gray)), + Span::styled(created_str, Style::default().fg(Color::Yellow)), + ]) + }; let mut header_lines: Vec = vec![ Line::from(vec![ Span::styled("Order ID: ", Style::default().fg(Color::Gray)), @@ -321,16 +359,7 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap Span::styled("Status: ", Style::default().fg(Color::Gray)), Span::styled(status_label, Style::default().add_modifier(Modifier::BOLD)), ]), - Line::from(vec![ - Span::styled( - format!("Initiator: {initiator_role} "), - Style::default().fg(Color::Gray), - ), - Span::styled(initiator_pubkey_display, Style::default().fg(Color::Cyan)), - Span::raw(" "), - Span::styled("Created: ", Style::default().fg(Color::Gray)), - Span::styled(created_str, Style::default().fg(Color::Yellow)), - ]), + context_line, Line::from(vec![ Span::styled("Amount: ", Style::default().fg(Color::Gray)), Span::styled( @@ -395,20 +424,35 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap let footer_height = footer_height.saturating_add(if app.attachment_toast.is_some() { 1 } else { 0 }); - let file_count = count_order_attachments(app, &selected.order_id); - let mut attach_hints = FOOTER_CTRL_O_SEND_FILE.to_string(); + let file_count = if active_channel == UserChatChannel::Peer { + count_order_attachments(app, &selected.order_id) + } else { + 0 + }; + let mut attach_hints = if active_channel == UserChatChannel::Peer { + FOOTER_CTRL_O_SEND_FILE.to_string() + } else { + String::new() + }; if file_count > 0 { attach_hints.push_str(FOOTER_CTRL_S_SAVE_FILE); } - if app - .pending_order_attachment_sends - .contains_key(&selected.order_id) + if active_channel == UserChatChannel::Peer + && app + .pending_order_attachment_sends + .contains_key(&selected.order_id) { attach_hints.push_str(FOOTER_CTRL_SHIFT_O_RETRY); } - if app.sending_attachment_order_id.as_deref() == Some(selected.order_id.as_str()) { + if active_channel == UserChatChannel::Peer + && app.sending_attachment_order_id.as_deref() == Some(selected.order_id.as_str()) + { attach_hints.push_str(FOOTER_SENDING_ATTACHMENT); } + if solver_available { + attach_hints.push_str(" | "); + attach_hints.push_str(FOOTER_MYTRADES_TAB_CHAT); + } let attach_hints = attach_hints.as_str(); let main_chunks = Layout::new( @@ -438,23 +482,24 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap main_chunks[0], ); - let chat_messages = app - .order_chats - .get(&selected.order_id) - .cloned() - .unwrap_or_default(); + let chat_messages = match active_channel { + UserChatChannel::Peer => app.order_chats.get(&selected.order_id), + UserChatChannel::Solver => app.user_dispute_chats.get(&selected.order_id), + } + .cloned() + .unwrap_or_default(); let message_count = chat_messages.len(); let chat_title = if message_count > 0 { if file_count > 0 { format!( - "Order Chat ({} messages, {} file(s))", - message_count, file_count + "{} Chat ({} messages, {} file(s))", + active_channel, message_count, file_count ) } else { - format!("Order Chat ({} messages)", message_count) + format!("{} Chat ({} messages)", active_channel, message_count) } } else { - "Order Chat (no messages)".to_string() + format!("{} Chat (no messages)", active_channel) }; let chat_area = main_chunks[1]; let chat_block = Block::default() @@ -468,14 +513,15 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap // Match disputes/observer chat: content width reserves one column for the vertical scrollbar. let content_width = chat_inner.width.saturating_sub(1).max(1); - let (chat_lines, _, line_starts) = build_order_chat_content(&chat_messages, content_width); + let (chat_lines, _, line_starts) = + build_order_chat_content(&chat_messages, content_width, active_channel); app.order_chat_line_starts = line_starts; let content_height = chat_lines.len().min(u16::MAX as usize) as u16; if message_count > 0 { let order_id_key = selected.order_id.clone(); - if let Some((ref prev_id, last_count)) = app.order_chat_scroll_tracker { - if *prev_id == order_id_key { + if let Some((ref prev_id, prev_channel, last_count)) = app.order_chat_scroll_tracker { + if *prev_id == order_id_key && prev_channel == active_channel { if message_count > last_count { app.order_chat_scrollview_state.scroll_to_bottom(); } @@ -485,9 +531,9 @@ pub fn render_order_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut Ap } else { app.order_chat_scrollview_state.scroll_to_bottom(); } - app.order_chat_scroll_tracker = Some((order_id_key, message_count)); + app.order_chat_scroll_tracker = Some((order_id_key, active_channel, message_count)); } else { - app.order_chat_scroll_tracker = Some((selected.order_id.clone(), 0)); + app.order_chat_scroll_tracker = Some((selected.order_id.clone(), active_channel, 0)); } let mut scroll_view = ScrollView::new(Size::new(content_width, content_height.max(1))) @@ -709,7 +755,12 @@ pub fn push_local_order_chat_message( mod tests { use super::{render_order_in_progress, trailing_order_chat_input}; use crate::ui::helpers::OrderChatListItem; - use crate::ui::{AppState, UiMode, UserMode, UserRole}; + use crate::ui::key_handler::handle_tab_navigation; + use crate::ui::{ + AppState, OrderChatStaticHeader, Tab, UiMode, UserChatChannel, UserChatSender, UserMode, + UserOrderChatMessage, UserRole, UserTab, + }; + use crossterm::event::KeyCode; use mostro_core::prelude::Status; use ratatui::backend::TestBackend; use ratatui::text::Span; @@ -786,13 +837,16 @@ mod tests { seller_trade_pubkey: None, buyer_reputation: None, seller_reputation: None, + solver_pubkey: None, + dispute_id: None, }); app.order_chat_input = format!( "hidden-prefix-that-should-scroll-away-{}-visible-suffix", "x".repeat(80) ); - let backend = TestBackend::new(80, 24); + // Keep the message input usable on a terminal that is both narrow and short. + let backend = TestBackend::new(60, 15); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|frame| render_order_in_progress(frame, frame.area(), &mut app)) @@ -806,6 +860,64 @@ mod tests { )); } + #[test] + fn render_solver_chat_keeps_solver_messages_visible() { + let order_id = Uuid::nil().to_string(); + let mut app = AppState::new(UserRole::User); + app.mode = UiMode::UserMode(UserMode::Normal); + app.active_user_chat_channel = UserChatChannel::Solver; + app.order_chat_static.insert( + Uuid::nil(), + OrderChatStaticHeader { + order_id: Uuid::nil(), + kind: None, + created_at: Some(1), + trade_index: 1, + initiator_trade_pubkey: "trade-pubkey".to_string(), + is_mine: false, + solver_pubkey: Some("solver-pubkey".to_string()), + dispute_id: Some("11111111-2222-3333-4444-555555555555".to_string()), + }, + ); + app.my_trades_maker_book.push(OrderChatListItem { + order_id: order_id.clone(), + status: Some(Status::Dispute), + amount: Some(1000), + fiat: Some((10, "USD".to_string())), + trade_index: Some(1), + payment_method: Some("cash".to_string()), + premium: Some(0), + buyer_trade_pubkey: None, + seller_trade_pubkey: None, + buyer_reputation: None, + seller_reputation: None, + solver_pubkey: Some("solver-pubkey".to_string()), + dispute_id: None, + }); + app.user_dispute_chats.insert( + order_id, + vec![UserOrderChatMessage { + sender: UserChatSender::Peer, + content: "Please send the payment receipt".to_string(), + timestamp: 1, + attachment: None, + }], + ); + + let backend = TestBackend::new(60, 15); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_order_in_progress(frame, frame.area(), &mut app)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + assert!(buffer_contains(buffer, "Solver Chat (1 messages)")); + assert!(buffer_contains(buffer, "Dispute ID:")); + assert!(buffer_contains(buffer, "Please send the")); + assert!(buffer_contains(buffer, "payment receipt")); + assert!(!buffer_contains(buffer, "Ctrl+O: Send file")); + } + #[test] fn render_footer_lists_dispute_shortcut_next_to_cancel() { let order_id = Uuid::nil().to_string(); @@ -823,6 +935,8 @@ mod tests { seller_trade_pubkey: None, buyer_reputation: None, seller_reputation: None, + solver_pubkey: None, + dispute_id: None, }); let backend = TestBackend::new(120, 24); @@ -837,4 +951,34 @@ mod tests { "Shift+C: Cancel order | Shift+D: Dispute" )); } + + #[test] + fn tab_switches_to_solver_chat_only_after_assignment() { + let mut app = AppState::new(UserRole::User); + app.active_tab = Tab::User(UserTab::MyTrades); + app.my_trades_maker_book.push(OrderChatListItem { + order_id: Uuid::nil().to_string(), + status: Some(Status::Dispute), + amount: None, + fiat: None, + trade_index: Some(1), + payment_method: None, + premium: None, + buyer_trade_pubkey: None, + seller_trade_pubkey: None, + buyer_reputation: None, + seller_reputation: None, + solver_pubkey: None, + dispute_id: None, + }); + + handle_tab_navigation(KeyCode::Tab, &mut app); + assert_eq!(app.active_user_chat_channel, UserChatChannel::Peer); + + app.my_trades_maker_book[0].solver_pubkey = Some("solver".to_string()); + handle_tab_navigation(KeyCode::Tab, &mut app); + assert_eq!(app.active_user_chat_channel, UserChatChannel::Solver); + handle_tab_navigation(KeyCode::BackTab, &mut app); + assert_eq!(app.active_user_chat_channel, UserChatChannel::Peer); + } } diff --git a/src/util/chat_listener.rs b/src/util/chat_listener.rs index 99951b2c..7616f19c 100644 --- a/src/util/chat_listener.rs +++ b/src/util/chat_listener.rs @@ -30,9 +30,10 @@ use uuid::Uuid; use crate::models::Order; use crate::ui::helpers::{ - load_dispute_chat_inner_ids, load_order_chat_inner_ids, order_chat_since_from_file, + load_dispute_chat_inner_ids, load_order_chat_inner_ids, load_user_dispute_chat_inner_ids, + order_chat_since_from_file, }; -use crate::ui::{AdminChatUpdate, ChatParty, DecodedChatMessage, OrderChatUpdate}; +use crate::ui::{AdminChatUpdate, ChatParty, DecodedChatMessage, OrderChatUpdate, UserChatChannel}; use crate::util::chat_security::{ try_emit_chat_update, ChatRateLimiters, OuterIdLru, CHAT_SEEN_OUTER_CAP, }; @@ -48,6 +49,8 @@ use futures::StreamExt; pub enum ChatKeyId { /// User P2P order chat, keyed by order id (UUID string). Order(String), + /// User-to-solver dispute chat, keyed by parent order id. + UserDispute(String), /// Admin dispute chat, keyed by dispute id + party (buyer/seller). Dispute(String, ChatParty), } @@ -160,6 +163,23 @@ pub fn track_order_chat( }); } +/// Track a user-to-solver dispute chat by its derived ECDH secret. +pub fn track_user_dispute_chat( + order_id: String, + shared_key_hex: String, + local_trade_pubkey: PublicKey, + solver_pubkey: PublicKey, + since: Option, +) { + send_chat_router_cmd(ChatRouterCmd::TrackChatKey { + key_id: ChatKeyId::UserDispute(order_id), + shared_key_hex, + local_trade_pubkey: Some(local_trade_pubkey), + allowed_signers: vec![local_trade_pubkey, solver_pubkey], + since, + }); +} + /// Stop tracking a user P2P order chat (order row removed / terminal cancel). pub fn untrack_order_chat(order_id: String) { send_chat_router_cmd(ChatRouterCmd::UntrackChatKey { @@ -167,6 +187,13 @@ pub fn untrack_order_chat(order_id: String) { }); } +/// Stop tracking a user-to-solver dispute chat for an order. +pub fn untrack_user_dispute_chat(order_id: String) { + send_chat_router_cmd(ChatRouterCmd::UntrackChatKey { + key_id: ChatKeyId::UserDispute(order_id), + }); +} + /// Track an admin dispute chat party by its shared key. /// /// `allowed_signers` must include that party's trade pubkey and, when known, @@ -262,12 +289,29 @@ fn emit_messages( user_tx, vec![OrderChatUpdate { order_id: order_id.clone(), + channel: UserChatChannel::Peer, local_trade_pubkey, messages, }], "order-chat", ); } + ChatKeyId::UserDispute(order_id) => { + let Some(local_trade_pubkey) = target.local_trade_pubkey else { + log::warn!("User dispute chat {order_id} missing local trade pubkey"); + return; + }; + let _ = try_emit_chat_update( + user_tx, + vec![OrderChatUpdate { + order_id: order_id.clone(), + channel: UserChatChannel::Solver, + local_trade_pubkey, + messages, + }], + "solver-chat", + ); + } ChatKeyId::Dispute(dispute_id, party) => { let _ = try_emit_chat_update( admin_tx, @@ -524,6 +568,7 @@ fn apply_chat_router_cmd( fn load_inner_ids_for_key(key_id: &ChatKeyId) -> HashSet { match key_id { ChatKeyId::Order(order_id) => load_order_chat_inner_ids(order_id), + ChatKeyId::UserDispute(order_id) => load_user_dispute_chat_inner_ids(order_id), ChatKeyId::Dispute(dispute_id, party) => load_dispute_chat_inner_ids(dispute_id, *party), } } diff --git a/src/util/chat_utils.rs b/src/util/chat_utils.rs index 2d2476db..5f0dab88 100644 --- a/src/util/chat_utils.rs +++ b/src/util/chat_utils.rs @@ -827,6 +827,35 @@ mod tests { assert_eq!(unwrapped.content, content); } + #[tokio::test] + async fn user_solver_chat_roundtrip_accepts_only_conversation_parties() { + let user = Keys::generate(); + let solver = Keys::generate(); + let shared = + SharedKey::derive(user.secret_key(), &solver.public_key()).expect("shared key derives"); + let (conv, sign) = shared.chat_keys().expect("chat keys derive"); + let event = wrap_chat_message(&user, &conv, &sign, "evidence sent") + .await + .expect("chat wraps"); + + let allowed = [user.public_key(), solver.public_key()]; + let unwrapped = unwrap_giftwrap_with_shared_key(shared.keys(), &event, &allowed) + .await + .expect("conversation party unwraps"); + assert_eq!(unwrapped.content, "evidence sent"); + assert_eq!(unwrapped.sender, user.public_key()); + + let stranger = Keys::generate(); + let stranger_event = wrap_chat_message(&stranger, &conv, &sign, "spoof") + .await + .expect("envelope builds"); + assert!( + unwrap_giftwrap_with_shared_key(shared.keys(), &stranger_event, &allowed) + .await + .is_err() + ); + } + #[tokio::test] async fn dual_read_unwraps_giftwrap_and_kind14_fixtures() { let sender = Keys::generate(); diff --git a/src/util/dm_utils/mod.rs b/src/util/dm_utils/mod.rs index 01a4ed9c..db73f2d2 100644 --- a/src/util/dm_utils/mod.rs +++ b/src/util/dm_utils/mod.rs @@ -27,10 +27,14 @@ use tokio::sync::{mpsc, oneshot}; use uuid::Uuid; use crate::models::{Order, User}; +use crate::ui::helpers::user_dispute_chat_since_from_file; use crate::ui::order_message_to_notification; use crate::ui::orders::{merge_order_snapshots, small_order_from_payload}; use crate::ui::{MessageNotification, OrderMessage}; -use crate::util::chat_listener::{maybe_track_order_chat, untrack_order_chat}; +use crate::util::chat_listener::{ + maybe_track_order_chat, track_user_dispute_chat, untrack_order_chat, untrack_user_dispute_chat, +}; +use crate::util::chat_utils::derive_shared_key_hex; use crate::util::db_utils::{delete_order_by_id, save_order, update_order_status}; use crate::util::filters::filter_protocol_dm_from_mostro; use crate::util::mostro_info::{ @@ -503,6 +507,7 @@ async fn drop_pre_active_taker_take( remove_order_from_messages(messages, order_id); // Row deleted: stop the P2P order chat subscription for this order. untrack_order_chat(order_id.to_string()); + untrack_user_dispute_chat(order_id.to_string()); } /// Refreshes the local `orders` row from embedded order data on trade DMs that carry a full @@ -618,6 +623,7 @@ async fn revert_maker_to_pending_on_book_republish( remove_order_from_messages(messages, order_id); // Back on the book as a pending maker listing (no counterparty): stop order chat subscription. untrack_order_chat(order_id.to_string()); + untrack_user_dispute_chat(order_id.to_string()); try_notify_my_trades_maker_book_changed(); log::info!( @@ -768,9 +774,6 @@ 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::CantDo) { - return; - } // Trade-DM `NewOrder` special cases only (create-order `NewOrder` uses the waiter path). // Unhandled shapes fall through to generic hydration with `new_order_would_regress_messages_row`. if matches!(action, Action::NewOrder) { @@ -798,7 +801,13 @@ async fn handle_trade_dm_for_order( let had_local_row_before_upsert = db_order.is_some(); let status_from_db = db_order.as_ref().and_then(order_status_from_row); - let status_candidate = resolved_status_candidate(&action, &inner_kind.payload); + // `CantDo` reports that a requested action was rejected; surface it without + // applying any status carried by its payload to the local order. + let status_candidate = if matches!(action, Action::CantDo) { + None + } else { + resolved_status_candidate(&action, &inner_kind.payload) + }; // Taker pre-Active cancel returns the order to the book; drop stale local row instead of // keeping it as terminal trade state. @@ -810,15 +819,65 @@ async fn handle_trade_dm_for_order( return; } - upsert_order_from_trade_dm( - pool, - order_id, - &action, - &inner_kind.payload, - inner_kind.request_id, - trade_keys, - ) - .await; + if !matches!(action, Action::CantDo) { + upsert_order_from_trade_dm( + pool, + order_id, + &action, + &inner_kind.payload, + inner_kind.request_id, + trade_keys, + ) + .await; + } + + if matches!( + action, + Action::DisputeInitiatedByYou | Action::DisputeInitiatedByPeer + ) { + if let Some(Payload::Dispute(dispute_id, _)) = inner_kind.payload.as_ref() { + if let Err(e) = + Order::update_dispute_id(pool, &order_id.to_string(), &dispute_id.to_string()).await + { + log::warn!("Failed to persist dispute id for order {order_id}: {e}"); + } + } + } + + if matches!(action, Action::AdminTookDispute) { + if let Some(Payload::Peer(peer)) = inner_kind.payload.as_ref() { + match PublicKey::parse(&peer.pubkey) { + Ok(solver_pubkey) => { + if let Some(shared_hex) = + derive_shared_key_hex(Some(trade_keys), Some(&peer.pubkey)) + { + if let Err(e) = Order::update_solver_chat( + pool, + &order_id.to_string(), + &peer.pubkey, + &shared_hex, + ) + .await + { + log::warn!("Failed to persist solver chat for order {order_id}: {e}"); + } else { + let since = user_dispute_chat_since_from_file(&order_id.to_string()); + track_user_dispute_chat( + order_id.to_string(), + shared_hex, + trade_keys.public_key(), + solver_pubkey, + since, + ); + } + } + } + Err(e) => log::warn!( + "AdminTookDispute carried invalid solver pubkey for order {order_id}: {e}" + ), + } + } + } // Keep the P2P order chat subscription live once the shared key is resolvable (idempotent). maybe_track_order_chat(pool, order_id, trade_keys).await; @@ -1184,6 +1243,7 @@ async fn dispatch_giftwrap_batch( if should_untrack_chat { untrack_order_chat(order_id.to_string()); + untrack_user_dispute_chat(order_id.to_string()); } if has_terminal_status { @@ -1984,14 +2044,95 @@ pub async fn listen_for_order_messages( #[cfg(test)] mod tests { use super::{ - default_dm_expiration, effective_is_mine_for_trade_dm_message, is_own_signed_v2_outbound, - is_pre_active_maker_listing, is_pre_active_taker_take, + default_dm_expiration, effective_is_mine_for_trade_dm_message, handle_trade_dm_for_order, + is_own_signed_v2_outbound, is_pre_active_maker_listing, is_pre_active_taker_take, new_order_would_regress_messages_row, small_order_pending_from_new_order_payload, trade_message_is_terminal, trade_message_should_untrack_order_chat, }; use crate::models::Order; + use crate::ui::orders::message_action_compact_label_for_message; use mostro_core::prelude::{Action, Message, Payload, SmallOrder, Status, UnwrappedMessage}; use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag, Timestamp}; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + #[tokio::test] + async fn cant_do_surfaces_rejection_without_changing_order_status() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory database"); + sqlx::query( + r#" + CREATE TABLE orders ( + id TEXT PRIMARY KEY, kind TEXT, status TEXT, amount INTEGER NOT NULL, + fiat_code TEXT NOT NULL, min_amount INTEGER, max_amount INTEGER, + fiat_amount INTEGER NOT NULL, payment_method TEXT NOT NULL, + premium INTEGER NOT NULL, trade_keys TEXT, counterparty_pubkey TEXT, + order_chat_shared_key_hex TEXT, dispute_id TEXT, solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, + buyer_invoice TEXT, request_id INTEGER, trade_index INTEGER, + created_at INTEGER, expires_at INTEGER, last_seen_dm_ts INTEGER + ) + "#, + ) + .execute(&pool) + .await + .expect("orders table"); + + let order_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO orders (id, kind, status, amount, fiat_code, fiat_amount, \ + payment_method, premium, is_mine) VALUES (?, 'buy', 'active', 1000, 'USD', 10, \ + 'bank', 0, 1)", + ) + .bind(order_id.to_string()) + .execute(&pool) + .await + .expect("active order"); + + let messages = Arc::new(Mutex::new(Vec::new())); + let pending_notifications = Arc::new(Mutex::new(0)); + let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel(); + let trade_keys = Keys::generate(); + let message = Message::new_order( + Some(order_id), + None, + Some(7), + Action::CantDo, + Some(Payload::Order(SmallOrder { + status: Some(Status::Canceled), + ..Default::default() + })), + ); + + handle_trade_dm_for_order( + &messages, + &pending_notifications, + ¬ification_tx, + order_id, + 7, + message, + 100, + Keys::generate().public_key(), + &pool, + &trade_keys, + true, + ) + .await; + + let stored = Order::get_by_id(&pool, &order_id.to_string()) + .await + .expect("stored order"); + assert_eq!(stored.status.as_deref(), Some("active")); + let messages = messages.lock().expect("messages lock"); + assert_eq!(messages.len(), 1); + assert_eq!( + message_action_compact_label_for_message(&messages[0]), + "Action Rejected" + ); + assert_eq!(*pending_notifications.lock().expect("pending lock"), 1); + assert!(notification_rx.try_recv().is_ok()); + } #[test] fn own_signed_v2_outbound_is_skipped_by_waiter_guard() { @@ -2103,6 +2244,9 @@ mod tests { trade_keys: None, counterparty_pubkey: None, order_chat_shared_key_hex: None, + dispute_id: None, + solver_pubkey: None, + dispute_chat_shared_key_hex: None, is_mine, buyer_invoice: None, request_id: Some(1), diff --git a/src/util/dm_utils/notifications_ch_mng.rs b/src/util/dm_utils/notifications_ch_mng.rs index 18106d09..7e5b7283 100644 --- a/src/util/dm_utils/notifications_ch_mng.rs +++ b/src/util/dm_utils/notifications_ch_mng.rs @@ -269,6 +269,17 @@ pub fn apply_open_invoice_popup_from_execute( /// Handle message notification from the notification channel pub fn handle_message_notification(notification: MessageNotification, app: &mut AppState) { + if let Some(order_id) = notification.order_id { + if let Some(header) = app.order_chat_static.get_mut(&order_id) { + if notification.solver_pubkey.is_some() { + header.solver_pubkey.clone_from(¬ification.solver_pubkey); + } + if notification.dispute_id.is_some() { + header.dispute_id.clone_from(¬ification.dispute_id); + } + } + } + // Only show popup automatically for PayInvoice / PayBondInvoice / AddInvoice, // and only if we haven't already shown it for this message. match notification.action { @@ -310,3 +321,77 @@ pub fn handle_message_notification(notification: MessageNotification, app: &mut _ => {} } } + +#[cfg(test)] +mod tests { + use super::handle_message_notification; + use crate::ui::{AppState, MessageNotification, OrderChatStaticHeader, UserRole}; + use mostro_core::prelude::{Action, Kind}; + use uuid::Uuid; + + fn notification( + order_id: Uuid, + action: Action, + solver_pubkey: Option<&str>, + dispute_id: Option<&str>, + ) -> MessageNotification { + MessageNotification { + order_id: Some(order_id), + message_preview: String::new(), + timestamp: 1, + action, + sat_amount: None, + invoice: None, + body: None, + maker_bond_publish: false, + solver_pubkey: solver_pubkey.map(str::to_string), + dispute_id: dispute_id.map(str::to_string), + } + } + + #[test] + fn dispute_metadata_survives_later_notifications() { + let order_id = Uuid::new_v4(); + let mut app = AppState::new(UserRole::User); + app.order_chat_static.insert( + order_id, + OrderChatStaticHeader { + order_id, + kind: Some(Kind::Buy), + created_at: None, + trade_index: 1, + initiator_trade_pubkey: "initiator".to_string(), + is_mine: false, + solver_pubkey: None, + dispute_id: None, + }, + ); + + handle_message_notification( + notification( + order_id, + Action::DisputeInitiatedByYou, + None, + Some("dispute-id"), + ), + &mut app, + ); + handle_message_notification( + notification( + order_id, + Action::AdminTookDispute, + Some("solver-pubkey"), + None, + ), + &mut app, + ); + handle_message_notification( + notification(order_id, Action::FiatSent, None, None), + &mut app, + ); + + let header = app.order_chat_static.get(&order_id).expect("static header"); + assert_eq!(header.dispute_id.as_deref(), Some("dispute-id")); + assert_eq!(header.solver_pubkey.as_deref(), Some("solver-pubkey")); + } +} diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index 149c5642..e65963b2 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -262,6 +262,8 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) invoice: Some(invoice.clone()), body: None, maker_bond_publish: order.status == Some(mostro_core::order::Status::WaitingMakerBond), + solver_pubkey: None, + dispute_id: None, }; let invoice_state = InvoiceInputState { @@ -495,6 +497,8 @@ mod tests { trade_index: 1, initiator_trade_pubkey: "pk".to_string(), is_mine: true, + solver_pubkey: None, + dispute_id: None, }, action: Action::PayBondInvoice, }, diff --git a/src/util/mod.rs b/src/util/mod.rs index da491d52..9d271bf5 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -22,8 +22,8 @@ pub use blossom::{ }; pub use chat_listener::{ listen_for_chat_messages, set_chat_router_cmd_tx, track_dispute_chat, track_order_chat, - untrack_dispute_chat, untrack_dispute_chat_parties, untrack_order_chat, ChatKeyId, - ChatRouterCmd, + track_user_dispute_chat, untrack_dispute_chat, untrack_dispute_chat_parties, + untrack_order_chat, untrack_user_dispute_chat, ChatKeyId, ChatRouterCmd, }; pub use chat_utils::send_admin_chat_message_via_shared_key; pub use db_utils::save_order; diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 0ef472a1..6a1f212a 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -547,6 +547,8 @@ pub(super) fn build_order_chat_static_header( trade_index, initiator_trade_pubkey: trade_keys.public_key().to_string(), is_mine, + solver_pubkey: None, + dispute_id: None, }) } diff --git a/src/util/order_utils/relay_order_db_reconcile.rs b/src/util/order_utils/relay_order_db_reconcile.rs index 5f6ba282..95dc45d0 100644 --- a/src/util/order_utils/relay_order_db_reconcile.rs +++ b/src/util/order_utils/relay_order_db_reconcile.rs @@ -174,6 +174,9 @@ mod tests { trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, @@ -251,6 +254,9 @@ mod tests { trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, @@ -312,6 +318,9 @@ mod tests { trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 03d7a262..aa8eaa34 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -23,6 +23,9 @@ pub async fn create_test_db() -> Result { trade_keys TEXT, counterparty_pubkey TEXT, order_chat_shared_key_hex TEXT, + dispute_id TEXT, + solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, buyer_invoice TEXT, request_id INTEGER, diff --git a/tests/db_tests.rs b/tests/db_tests.rs index 5362e20d..d1a89456 100644 --- a/tests/db_tests.rs +++ b/tests/db_tests.rs @@ -155,6 +155,50 @@ async fn test_order_get_by_id_not_found() { assert!(result.is_err()); } +#[tokio::test] +async fn test_order_persists_user_solver_chat_metadata() { + let pool = create_test_db().await.unwrap(); + let trade_keys = Keys::generate(); + let solver = Keys::generate(); + let order_id = uuid::Uuid::new_v4(); + let small_order = SmallOrder { + id: Some(order_id), + fiat_code: "USD".to_string(), + payment_method: "cash".to_string(), + ..Default::default() + }; + Order::new(&pool, small_order, &trade_keys, None, 1, true) + .await + .unwrap(); + + let dispute_id = uuid::Uuid::new_v4().to_string(); + let solver_pubkey = solver.public_key().to_string(); + Order::update_dispute_id(&pool, &order_id.to_string(), &dispute_id) + .await + .unwrap(); + Order::update_solver_chat( + &pool, + &order_id.to_string(), + &solver_pubkey, + "shared-secret-hex", + ) + .await + .unwrap(); + + let stored = Order::get_by_id(&pool, &order_id.to_string()) + .await + .unwrap(); + assert_eq!(stored.dispute_id.as_deref(), Some(dispute_id.as_str())); + assert_eq!( + stored.solver_pubkey.as_deref(), + Some(solver_pubkey.as_str()) + ); + assert_eq!( + stored.dispute_chat_shared_key_hex.as_deref(), + Some("shared-secret-hex") + ); +} + #[tokio::test] async fn test_order_update_existing() { let pool = create_test_db().await.unwrap();