diff --git a/src/main.rs b/src/main.rs index 1c0c310..d9e5936 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,13 @@ use crate::models::AdminDispute; use crate::models::User; use crate::settings::{init_settings, Settings}; use crate::ui::helpers::{ - admin_chat_keys_clone_for_role, apply_admin_chat_updates, apply_user_order_chat_updates, - expire_attachment_toast, load_admin_disputes_at_startup, prepare_post_restore_trade_dm_replay, - refresh_my_trades_maker_book_cache, spawn_post_restore_trade_dm_replay, - sync_user_order_history_messages_from_db, + active_peer_chat_order_ids_for_restore, admin_chat_keys_clone_for_role, + apply_admin_chat_updates, apply_restored_peer_order_chats_from_disk, + apply_user_order_chat_updates, clear_session_chat_projection, expire_attachment_toast, + load_admin_disputes_at_startup, prepare_post_restore_trade_dm_replay, + refresh_my_trades_maker_book_cache, spawn_post_restore_peer_chat_hydrate, + spawn_post_restore_trade_dm_replay, sync_user_order_history_messages_from_db, + track_startup_chats, }; use crate::ui::key_handler::{ append_paste_to_admin_dispute_chat, apply_paste_to_focused_key_input, @@ -61,7 +64,6 @@ use tokio::time::{interval, Duration}; /// Constructs (or copies) the configuration file and loads it. pub static SETTINGS: OnceLock = OnceLock::new(); -/// Applies one [`OperationResult`] from the background task channel (save attachment, orders, etc.). /// Results that must re-run the startup DB-to-UI sync (maker book cache + /// order history messages) because background work changed SQLite rows the /// in-memory projections are built from. @@ -72,6 +74,12 @@ fn requires_db_projection_resync(result: &OperationResult) -> bool { ) } +/// Applies one [`OperationResult`] from the background task channel (save attachment, orders, etc.). +/// +/// [`OperationResult::SessionRestored`] clears stale chat projection, resyncs DB-backed +/// Messages/My Trades rows, and spawns background trade-DM replay plus peer-chat relay +/// rebuild. [`OperationResult::PostRestorePeerChatReplayCompleted`] loads rebuilt peer +/// transcripts from disk and re-runs [`track_startup_chats`]. async fn apply_order_result( pool: &SqlitePool, app: &mut AppState, @@ -84,6 +92,11 @@ async fn apply_order_result( if matches!(&result, OperationResult::PostRestoreTradeDmReplayCompleted) { return; } + if let OperationResult::PostRestorePeerChatReplayCompleted { order_ids } = &result { + apply_restored_peer_order_chats_from_disk(app, order_ids); + track_startup_chats(pool, app).await; + return; + } let is_dispute_related = match &result { OperationResult::AdminDisputeDeleted { .. } => true, @@ -102,6 +115,10 @@ async fn apply_order_result( | OperationResult::SessionRestored { .. } ); + if is_session_restored && app.user_role == UserRole::User { + clear_session_chat_projection(app); + } + if refresh_maker_book_cache && app.user_role == UserRole::User { refresh_my_trades_maker_book_cache(pool, app).await; } @@ -110,6 +127,7 @@ async fn apply_order_result( result, OperationResult::MyTradesMakerBookChanged | OperationResult::PostRestoreTradeDmReplayCompleted + | OperationResult::PostRestorePeerChatReplayCompleted { .. } ) { handle_operation_result(result, app); } @@ -128,6 +146,13 @@ async fn apply_order_result( order_result_tx.clone(), ); } + let peer_order_ids = active_peer_chat_order_ids_for_restore(pool).await; + spawn_post_restore_peer_chat_hydrate( + pool.clone(), + client.clone(), + peer_order_ids, + order_result_tx.clone(), + ); } if is_dispute_related && app.user_role == UserRole::Admin { diff --git a/src/ui/helpers/mod.rs b/src/ui/helpers/mod.rs index d1b9f42..80fd48f 100644 --- a/src/ui/helpers/mod.rs +++ b/src/ui/helpers/mod.rs @@ -61,10 +61,13 @@ pub use order_selection::{ selected_book_display_idx, selected_filtered_book_order, }; pub use startup::{ - admin_chat_keys_clone_for_role, apply_admin_chat_updates, apply_user_order_chat_updates, + active_peer_chat_order_ids_for_restore, admin_chat_keys_clone_for_role, + apply_admin_chat_updates, apply_restored_peer_order_chats_from_disk, + apply_user_order_chat_updates, clear_session_chat_projection, hydrate_app_admin_keys_from_privkey, load_admin_disputes_at_startup, - load_user_order_chats_at_startup, prepare_post_restore_trade_dm_replay, - recover_admin_chat_from_files, refresh_my_trades_maker_book_cache, + load_user_order_chats_at_startup, peer_order_chat_transcript_from_decoded, + prepare_post_restore_trade_dm_replay, recover_admin_chat_from_files, + refresh_my_trades_maker_book_cache, spawn_post_restore_peer_chat_hydrate, spawn_post_restore_trade_dm_replay, sync_user_order_history_messages_from_db, track_startup_chats, }; diff --git a/src/ui/helpers/startup.rs b/src/ui/helpers/startup.rs index 96fb35d..1130610 100644 --- a/src/ui/helpers/startup.rs +++ b/src/ui/helpers/startup.rs @@ -13,15 +13,17 @@ use uuid::Uuid; 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, - MessageNotification, OperationResult, OrderChatLastSeen, OrderChatStaticHeader, OrderMessage, - UserChatChannel, UserChatSender, UserOrderChatMessage, UserRole, + AdminChatLastSeen, AdminChatUpdate, AppState, ChatParty, DecodedChatMessage, + DisputeChatMessage, MessageNotification, OperationResult, OrderChatLastSeen, + OrderChatStaticHeader, OrderMessage, UserChatChannel, UserChatSender, UserOrderChatMessage, + UserRole, }; use crate::util::{ 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, + dispute_chat_role_for_inner_signer, fetch_chat_messages_for_shared_key, + keys_from_shared_hex, order_chat_allowed_signers, parse_chat_pubkey, }, hydrate_startup_active_order_dm_state, replay_active_trade_dms, seed_admin_chat_last_seen, }; @@ -158,7 +160,9 @@ pub async fn load_admin_disputes_at_startup(pool: &SqlitePool, app: &mut AppStat /// /// Commands are buffered on the router's channel until the task starts consuming them, so this /// is safe to call before the chat router task is spawned. History for each key is hydrated by -/// the router on `TrackChatKey` using the passed `since` (last-seen) cursor. +/// the router on `TrackChatKey` using the passed `since` (last-seen) cursor. After +/// [`OperationResult::SessionRestored`], call again once peer-chat transcripts are on disk +/// ([`OperationResult::PostRestorePeerChatReplayCompleted`]) so live subscriptions cover rebuilt orders. pub async fn track_startup_chats(pool: &SqlitePool, app: &AppState) { match app.user_role { UserRole::User => { @@ -276,7 +280,9 @@ pub async fn track_startup_chats(pool: &SqlitePool, app: &AppState) { /// Load user order chat at startup from on-disk transcripts. /// /// Relay history is **not** polled here — [`track_startup_chats`] seeds the shared-key chat -/// router, which hydrates once per key on `TrackChatKey` (avoids a duplicate fetch). +/// router, which hydrates once per key on `TrackChatKey` (avoids a duplicate fetch). After +/// [`OperationResult::SessionRestored`], peer transcripts are rebuilt from relay via +/// [`spawn_post_restore_peer_chat_hydrate`] instead of relying on this path alone. pub async fn load_user_order_chats_at_startup(pool: &SqlitePool, app: &mut AppState) { if app.user_role != UserRole::User { return; @@ -560,6 +566,34 @@ pub async fn sync_user_order_history_messages_from_db(pool: &SqlitePool, app: &m } } +/// Clear in-memory peer/solver chat transcripts and relay cursors. +/// +/// After a session wipe or before post-restore hydrate, stale `order_chat_last_seen` +/// values from the prior identity would bound relay fetches incorrectly and echo-skip +/// logic would drop the user's own messages when the on-disk transcript is empty. +pub fn clear_session_chat_projection(app: &mut AppState) { + app.order_chats.clear(); + app.user_dispute_chats.clear(); + app.order_chat_last_seen.clear(); + app.user_dispute_chat_last_seen.clear(); + app.order_chat_static.clear(); + app.startup_popup_floor_ts.clear(); + app.buyer_invoice_preference.clear(); + app.orders_needing_replacement_invoice.clear(); + app.my_trades_maker_book.clear(); + app.pending_order_attachment_sends.clear(); + app.sending_attachment_order_id = None; + app.selected_order_chat_idx = 0; + app.order_chat_input.clear(); + app.order_chat_input_enabled = false; + app.order_chat_selected_message_idx = None; + app.order_chat_line_starts.clear(); + app.order_chat_scroll_tracker = None; + if let Ok(mut dropped) = app.dropped_user_history_order_ids.lock() { + dropped.clear(); + } +} + /// Snapshot of app/DB state needed for a background post-restore trade-DM replay. pub struct PostRestoreTradeDmReplayJob { transport: Transport, @@ -656,8 +690,231 @@ pub fn spawn_post_restore_trade_dm_replay( }); } +/// Outcome of relay rebuild for peer order chats after session restore. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PeerOrderChatRestoreSummary { + pub attempted: usize, + pub hydrated: usize, + pub skipped_no_key: usize, + pub fetch_failed: usize, + pub empty: usize, +} + +fn peer_chat_sender_for_decode( + local_trade_pubkey: &PublicKey, + inner_sender: &PublicKey, +) -> UserChatSender { + if inner_sender == local_trade_pubkey { + UserChatSender::You + } else { + UserChatSender::Peer + } +} + +/// Build a sorted peer-chat transcript from relay-decoded rows (deduped by inner event id). +pub fn peer_order_chat_transcript_from_decoded( + decoded_messages: Vec, + local_trade_pubkey: PublicKey, +) -> Vec { + use std::collections::HashSet; + + let mut seen_inner = HashSet::new(); + let mut transcript = Vec::new(); + for decoded in decoded_messages { + if !seen_inner.insert(decoded.inner_event_id) { + continue; + } + let (content, attachment) = match try_parse_attachment_message(&decoded.content) { + Some((attachment, display)) => (display, Some(attachment)), + None => (decoded.content, None), + }; + transcript.push(UserOrderChatMessage { + sender: peer_chat_sender_for_decode(&local_trade_pubkey, &decoded.sender), + content, + timestamp: decoded.timestamp, + attachment, + }); + } + transcript.sort_by_key(|m| m.timestamp); + transcript +} + +/// Fetch peer order chat from relays, persist transcript, record inner ids for dedupe. +async fn rebuild_peer_order_chat_transcript( + client: &Client, + order: &Order, +) -> Result>, anyhow::Error> { + let order_id = order + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("order row missing id"))?; + let trade_keys_hex = order + .trade_keys + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("order {order_id} missing trade keys"))?; + let trade_keys = Keys::parse(trade_keys_hex)?; + let local_trade_pubkey = trade_keys.public_key(); + + 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 { + return Ok(None); + }; + let Some(shared_keys) = keys_from_shared_hex(&shared_hex) else { + return Err(anyhow::anyhow!( + "invalid shared key hex for order {order_id}" + )); + }; + let Some(allowed) = + order_chat_allowed_signers(local_trade_pubkey, order.counterparty_pubkey.as_deref()) + else { + return Ok(None); + }; + + let decoded = fetch_chat_messages_for_shared_key(client, &shared_keys, &allowed, None).await?; + if decoded.is_empty() { + return Ok(None); + } + + let mut seen_inner = std::collections::HashSet::new(); + let mut deduped = Vec::new(); + for msg in decoded { + if seen_inner.insert(msg.inner_event_id) { + deduped.push(msg); + } + } + let inner_event_ids: Vec<_> = deduped.iter().map(|m| m.inner_event_id).collect(); + + let transcript = peer_order_chat_transcript_from_decoded(deduped, local_trade_pubkey); + if transcript.is_empty() { + return Ok(None); + } + + if !rewrite_order_chat_messages(order_id, &transcript) { + return Err(anyhow::anyhow!( + "failed to persist peer chat transcript for order {order_id}" + )); + } + for inner_id in inner_event_ids { + let _ = remember_order_chat_inner_id(order_id, &inner_id); + } + + Ok(Some(transcript)) +} + +/// Relay-fetch peer chats for active restored orders (awaitable). +pub async fn rebuild_peer_order_chats_after_restore( + client: &Client, + pool: &SqlitePool, + order_ids: &[String], +) -> (PeerOrderChatRestoreSummary, Vec) { + let mut summary = PeerOrderChatRestoreSummary::default(); + let mut hydrated_ids = Vec::new(); + + for order_id in order_ids { + summary.attempted += 1; + let order = match Order::get_by_id(pool, order_id).await { + Ok(o) => o, + Err(e) => { + log::warn!("Post-restore peer chat: order {order_id} not in DB: {e}"); + summary.skipped_no_key += 1; + continue; + } + }; + if order + .order_chat_shared_key_hex + .as_deref() + .filter(|s| !s.is_empty()) + .is_none() + && order.counterparty_pubkey.is_none() + { + summary.skipped_no_key += 1; + continue; + } + + match rebuild_peer_order_chat_transcript(client, &order).await { + Ok(Some(_)) => { + summary.hydrated += 1; + hydrated_ids.push(order_id.clone()); + } + Ok(None) => summary.empty += 1, + Err(e) => { + log::warn!("Post-restore peer chat: rebuild failed for {order_id}: {e}"); + summary.fetch_failed += 1; + } + } + } + + (summary, hydrated_ids) +} + +/// Load rebuilt peer-chat transcripts from disk into `app` (after background restore). +pub fn apply_restored_peer_order_chats_from_disk(app: &mut AppState, order_ids: &[String]) { + for order_id in order_ids { + let Some(messages) = load_order_chat_from_file(order_id) else { + continue; + }; + let max_ts = messages.iter().map(|m| m.timestamp).max().unwrap_or(0); + app.order_chats.insert(order_id.clone(), messages); + app.order_chat_last_seen.insert( + order_id.clone(), + OrderChatLastSeen { + last_seen_timestamp: Some(clamp_chat_since_cursor_now(max_ts)), + }, + ); + } +} + +/// Active order ids eligible for post-restore peer-chat relay rebuild. +pub async fn active_peer_chat_order_ids_for_restore(pool: &SqlitePool) -> Vec { + match Order::get_startup_active_orders(pool).await { + Ok(rows) => rows.into_iter().map(|r| r.id).collect(), + Err(e) => { + log::warn!("Post-restore peer chat: failed to list active orders: {e}"); + Vec::new() + } + } +} + +/// Spawn peer-chat relay rebuild without blocking the UI loop; reports +/// [`OperationResult::PostRestorePeerChatReplayCompleted`] with hydrated `order_ids`. +pub fn spawn_post_restore_peer_chat_hydrate( + pool: SqlitePool, + client: Client, + order_ids: Vec, + order_result_tx: UnboundedSender, +) { + if order_ids.is_empty() { + return; + } + tokio::spawn(async move { + let (summary, hydrated_ids) = + rebuild_peer_order_chats_after_restore(&client, &pool, &order_ids).await; + + log::info!( + "Post-restore peer chat rebuild: attempted={} hydrated={} empty={} fetch_failed={} skipped_no_key={}", + summary.attempted, + summary.hydrated, + summary.empty, + summary.fetch_failed, + summary.skipped_no_key + ); + + let _ = order_result_tx.send(OperationResult::PostRestorePeerChatReplayCompleted { + order_ids: hydrated_ids, + }); + }); +} + /// Merge fetched user order chat updates into app state and persist them to file. /// +/// On [`UserChatChannel::Peer`], relay rows from the local trade key are stored as **You** +/// unless the inner event id is already known or an optimistic local line exists at the same +/// timestamp (live-send echo). The solver channel still skips all local-trade-key rows. +/// /// Durable inner-event ids are recorded only after a successful transcript /// [`save_order_chat_message`] / [`rewrite_order_chat_messages`]. On write /// failure the id is left unrecorded so a later delivery can retry. @@ -682,8 +939,27 @@ pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec max_ts { + max_ts = ts; + } + continue; + } + let optimistic_echo = messages_vec.iter().any(|m| { + m.sender == UserChatSender::You && m.timestamp == ts && m.content == content + }); + if optimistic_echo { + let _ = remember_order_chat_inner_id(&order_id, &inner_id); + if ts > max_ts { + max_ts = ts; + } + continue; + } + } else if update.channel == UserChatChannel::Solver + && sender_pubkey == update.local_trade_pubkey + { if ts > max_ts { max_ts = ts; } @@ -781,8 +1057,17 @@ pub fn apply_user_order_chat_updates(app: &mut AppState, updates: Vec { + UserChatSender::You + } + UserChatChannel::Peer => UserChatSender::Peer, + UserChatChannel::Solver => UserChatSender::Peer, + }; + let msg = UserOrderChatMessage { - sender: UserChatSender::Peer, + sender: sender_label, content: msg_content, timestamp: ts, attachment, @@ -1025,6 +1310,104 @@ pub async fn apply_admin_chat_updates( Ok(()) } +#[cfg(test)] +mod peer_order_chat_restore_tests { + use super::peer_order_chat_transcript_from_decoded; + use crate::ui::{DecodedChatMessage, UserChatSender}; + use nostr_sdk::prelude::{EventId, Keys}; + + fn decoded( + ts: i64, + sender: nostr_sdk::prelude::PublicKey, + content: &str, + id_byte: u8, + ) -> DecodedChatMessage { + let mut hex = [0u8; 32]; + hex[31] = id_byte; + DecodedChatMessage { + content: content.to_string(), + timestamp: ts, + sender, + inner_event_id: EventId::from_byte_array(hex), + } + } + + #[test] + fn transcript_maps_you_and_peer_and_sorts_by_timestamp() { + let local = Keys::generate(); + let peer = Keys::generate(); + let messages = peer_order_chat_transcript_from_decoded( + vec![ + decoded(200, peer.public_key(), "from peer", 2), + decoded(100, local.public_key(), "from me", 1), + ], + local.public_key(), + ); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].content, "from me"); + assert_eq!(messages[0].sender, UserChatSender::You); + assert_eq!(messages[1].content, "from peer"); + assert_eq!(messages[1].sender, UserChatSender::Peer); + } +} + +#[cfg(test)] +mod clear_session_chat_projection_tests { + use super::clear_session_chat_projection; + use crate::ui::{AppState, OrderChatLastSeen, UserChatSender, UserOrderChatMessage, UserRole}; + use uuid::Uuid; + + #[test] + fn clears_peer_and_solver_chat_maps_and_cursors() { + let mut app = AppState::new(UserRole::User); + app.order_chats.insert( + "order-1".to_string(), + vec![UserOrderChatMessage { + sender: UserChatSender::Peer, + content: "hi".to_string(), + timestamp: 1, + attachment: None, + }], + ); + app.user_dispute_chats.insert("order-1".to_string(), vec![]); + app.order_chat_last_seen.insert( + "order-1".to_string(), + OrderChatLastSeen { + last_seen_timestamp: Some(9_999), + }, + ); + app.user_dispute_chat_last_seen.insert( + "order-1".to_string(), + OrderChatLastSeen { + last_seen_timestamp: Some(8_888), + }, + ); + app.startup_popup_floor_ts + .insert(Uuid::new_v4(), 1_700_000_000); + app.selected_order_chat_idx = 3; + app.order_chat_input = "draft".to_string(); + app.dropped_user_history_order_ids + .lock() + .expect("lock") + .insert(Uuid::new_v4()); + + clear_session_chat_projection(&mut app); + + assert!(app.order_chats.is_empty()); + assert!(app.user_dispute_chats.is_empty()); + assert!(app.order_chat_last_seen.is_empty()); + assert!(app.user_dispute_chat_last_seen.is_empty()); + assert!(app.startup_popup_floor_ts.is_empty()); + assert_eq!(app.selected_order_chat_idx, 0); + assert!(app.order_chat_input.is_empty()); + assert!(app + .dropped_user_history_order_ids + .lock() + .expect("lock") + .is_empty()); + } +} + #[cfg(test)] mod history_action_for_db_order_tests { use super::history_action_for_db_order; diff --git a/src/ui/key_handler/async_tasks.rs b/src/ui/key_handler/async_tasks.rs index 60a3a4c..cd1342b 100644 --- a/src/ui/key_handler/async_tasks.rs +++ b/src/ui/key_handler/async_tasks.rs @@ -1,7 +1,9 @@ use crate::models::{Order, User}; use crate::settings::load_settings_from_disk; use crate::settings::Settings; -use crate::ui::helpers::{hydrate_app_admin_keys_from_privkey, track_startup_chats}; +use crate::ui::helpers::{ + clear_session_chat_projection, hydrate_app_admin_keys_from_privkey, track_startup_chats, +}; use crate::ui::key_handler::EnterKeyContext; use crate::ui::pending_trade_index_retry::PendingTradeIndexRetry; use crate::ui::FormState; @@ -202,6 +204,8 @@ fn reset_pending_notifications_or_fatal(app: &mut AppState) -> Result<(), ()> { Err(()) } +/// Reset in-memory trade/message state after identity key reload; also clears peer/solver +/// chat transcripts and cursors via [`clear_session_chat_projection`]. fn clear_runtime_session_state(app: &mut AppState) { if clear_messages_or_fatal(app).is_err() { return; @@ -212,6 +216,7 @@ fn clear_runtime_session_state(app: &mut AppState) { if reset_pending_notifications_or_fatal(app).is_err() { return; } + clear_session_chat_projection(app); app.selected_message_idx = 0; app.pending_post_take_operation_result = None; } diff --git a/src/ui/operation_result.rs b/src/ui/operation_result.rs index 753dfa3..6c28f04 100644 --- a/src/ui/operation_result.rs +++ b/src/ui/operation_result.rs @@ -303,6 +303,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) | OperationResult::AdminDisputeDeleted { .. } | OperationResult::MyTradesMakerBookChanged | OperationResult::PostRestoreTradeDmReplayCompleted + | OperationResult::PostRestorePeerChatReplayCompleted { .. } | OperationResult::OpenInvoicePopup { .. } | OperationResult::OrderChatAttachmentSent { .. } | OperationResult::OrderChatAttachmentSendFailed { .. } @@ -494,6 +495,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) } OperationResult::MyTradesMakerBookChanged | OperationResult::PostRestoreTradeDmReplayCompleted + | OperationResult::PostRestorePeerChatReplayCompleted { .. } | OperationResult::OpenInvoicePopup { .. } | OperationResult::OrderChatAttachmentSent { .. } | OperationResult::OrderChatAttachmentSendFailed { .. } diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 1ac3584..f7e1d89 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -196,8 +196,15 @@ pub enum OperationResult { MyTradesMakerBookChanged, /// Background post-restore trade-DM replay finished (silent; `messages` already updated). PostRestoreTradeDmReplayCompleted, - /// Session restore finished: resync My Trades/Messages projections from - /// SQLite (same DB-to-UI sync as startup), then show `message`. + /// Background post-restore peer order-chat relay rebuild finished (silent). + /// The main loop loads `order_ids` from disk into [`crate::ui::AppState::order_chats`] + /// and re-emits [`crate::ui::helpers::track_startup_chats`]. + PostRestorePeerChatReplayCompleted { + order_ids: Vec, + }, + /// Session restore finished: clear stale chat projection, resync My Trades/Messages + /// from SQLite, spawn background trade-DM and peer-chat relay rebuilds, then show + /// `message`. SessionRestored { message: String, }, diff --git a/src/util/order_utils/execute_restore.rs b/src/util/order_utils/execute_restore.rs index feefd74..69414c5 100644 --- a/src/util/order_utils/execute_restore.rs +++ b/src/util/order_utils/execute_restore.rs @@ -82,10 +82,11 @@ impl RestoreSummary { } /// Map the outcome of [`execute_restore_session`] to the operation result the -/// restore task must emit. `Ok` MUST become [`OperationResult::SessionRestored`] +/// restore task must emit. `Ok` MUST become [`crate::ui::OperationResult::SessionRestored`] /// — not a plain `Info` — because only that variant makes `apply_order_result` -/// re-run the DB-to-UI projection sync; with `Info` the restored rows stay -/// invisible until a later sync or restart. +/// clear stale chat projection, re-run the DB-to-UI sync, and spawn post-restore +/// relay hydrates; with `Info` the restored rows stay invisible until a later sync +/// or restart. pub fn restore_completion_result(outcome: &Result) -> crate::ui::OperationResult { match outcome { Ok(summary) => crate::ui::OperationResult::SessionRestored {