diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 8aaca649..caab2fd7 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -224,7 +224,7 @@ CREATE TABLE IF NOT EXISTS orders ( The `orders` table is essential for: - **Trade Key Persistence**: Stores the trade keys needed to decrypt messages and sign actions for each active trade -- **Order Recovery**: Allows the client to recover active orders on startup (`Order::get_startup_active_orders`, `hydrate_startup_active_order_dm_state`) +- **Order Recovery**: Allows the client to recover active orders on startup (`Order::get_startup_active_orders`, `hydrate_startup_active_order_dm_state`) and after **session restore** (`execute_restore_session` → post-restore hydrate in `src/ui/helpers/startup.rs`) - **State Synchronization**: Enables the "fetch-on-startup" strategy to sync with Mostro daemon - **Trade History**: Maintains a local record of orders and trades - **My Trades static header (UI)**: on user history sync, `sync_user_order_history_messages_from_db` in `src/ui/helpers/startup.rs` seeds `AppState.order_chat_static` from existing `orders` rows (`id`, `kind`, `created_at`, `trade_index`, `is_mine`, and trade public key derived from `trade_keys`) so the in-app header (order id, type, created time, trade index, initiator) is stable across process restarts without re-folding the DM list. @@ -421,7 +421,7 @@ Mostrix uses a hybrid message recovery strategy that combines stateless fetch-on - **User order chat (My Trades)**: - Transcripts under `~/.mostrix/orders_chat/.txt` (not in SQLite). - - Same JSON attachment persistence and legacy-placeholder hydration as admin chat; loaded by `load_user_order_chats_at_startup`. See [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — "User order chat local cache". + - Same JSON attachment persistence and legacy-placeholder hydration as admin chat; loaded by `load_user_order_chats_at_startup` at cold start and by `apply_restored_peer_order_chats_from_disk` after session restore. See [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — "User order chat local cache" and [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — "Session restore hydrate". This approach keeps the core trade DM flow largely stateless while giving admin and user order chat a robust, restart‑safe transcript cache. diff --git a/docs/DM_LISTENER_FLOW.md b/docs/DM_LISTENER_FLOW.md index 822caf4d..4fa366ec 100644 --- a/docs/DM_LISTENER_FLOW.md +++ b/docs/DM_LISTENER_FLOW.md @@ -107,6 +107,20 @@ Relay subscriptions alone often **do not** deliver enough stored history into th **Practical “where to look”**: `fetch_and_replay_startup_trade_dms`, struct **`DmListenerStartupReplay`**, **`dispatch_giftwrap_batch`** (batch of one at startup), and **`notify`** on **`handle_trade_dm_for_order`** / **`dispatch_giftwrap_batch`** in `src/util/dm_utils/mod.rs`. +### 4) Post-restore trade DM replay (session restore, no restart) + +Cold startup replay runs inside `listen_for_order_messages` bootstrap (section 3). **Session restore** (Settings → Restore Session) must refill the Messages tab **without** restarting the listener task. That path is separate: + +1. `apply_order_result` handles `OperationResult::SessionRestored` — clears chat projection, re-syncs DB-backed UI rows, then spawns **`spawn_post_restore_hydrate`** (`src/ui/helpers/startup.rs`). +2. **`prepare_post_restore_trade_dm_replay`** reloads `hydrate_startup_active_order_dm_state`, re-seeds `active_order_trade_indices` and `startup_popup_floor_ts` on `AppState`. +3. **`replay_active_trade_dms`** (awaitable; also used from the orchestrator) fetches per active order with **`trade_dm_replay_fetch_filter`**: + - **No `last_seen_dm_ts`** (post-wipe / fresh restore row): **limit-only** — no `since` — so relay retention bounds catch-up (not the 12h cold lookback). + - **Cursor present**: `since` from cursor ∩ lookback, minus GiftWrap envelope skew (`STARTUP_GIFTWRAP_ENVELOPE_SKEW_SECS`), plus fetch limit. +4. Dispatch uses **`UntrackedFallback`** when the live DM router has no `TrackOrder` subscription for the trade pubkey yet (common immediately after restore). +5. Updates `AppState.messages` with **`notify: false`** (no duplicate popups). Completion is folded into **`RestoreHydrateReport.trade_dm`** on **`PostRestoreHydrateCompleted`**. + +Peer order chat (My Trades panel) is a **separate pipe** — shared-key kind-14 fetch in `rebuild_peer_order_chats_after_restore`, not the protocol-DM router. See [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — "Session restore hydrate". + ## Command “preferences”: TrackOrder vs Waiter The listener consumes a command channel (`dm_subscription_rx`) with two variants: @@ -349,6 +363,7 @@ Use this checklist when validating dual-transport behavior against live nodes: 1. **v1 node** (`protocol_version: "1"`) — create order, take, pay invoice, release; flows unchanged (GiftWrap filters). 2. **v2 node** (`protocol_version: "2"`) — same flows over kind-14 subscribe + `unwrap_incoming`. 3. **Mid-trade restart** — quit and relaunch Mostrix; startup `fetch_events` replay hydrates Messages tab state via the active transport filter. -4. **P2P order chat** — kind 14 outbound (`chat_utils.rs`); inbound still dual-reads legacy GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true. Unrelated to protocol v2 Mostro DM cutover. Full #102 matrix: [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md). -5. **Transport flip** (rare) — refresh Mostro Info when `protocol_version` changes; listener respawns with new filter shape. +4. **Session restore (no restart)** — Settings → Restore Session after seed import; Messages tab and My Trades peer chat hydrate via `spawn_post_restore_hydrate` without relaunching the DM listener. See [RESTORE_SESSION_ACCEPTANCE.md](RESTORE_SESSION_ACCEPTANCE.md). +5. **P2P order chat** — kind 14 outbound (`chat_utils.rs`); inbound still dual-reads legacy GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true. Unrelated to protocol v2 Mostro DM cutover. Full #102 matrix: [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md). +6. **Transport flip** (rare) — refresh Mostro Info when `protocol_version` changes; listener respawns with new filter shape. diff --git a/docs/KEY_MANAGEMENT.md b/docs/KEY_MANAGEMENT.md index 4ff54110..e6fe34ce 100644 --- a/docs/KEY_MANAGEMENT.md +++ b/docs/KEY_MANAGEMENT.md @@ -135,4 +135,5 @@ Mostrix avoids storing full message histories locally. Instead, it uses the dete 2. It re-derives the corresponding `Trade Keys`. 3. It queries Nostr relays for recent **protocol DM** events directed to those trade public keys — GiftWrap (kind 1059) or signed kind 14, depending on the Mostro instance `protocol_version` / [`Transport`](../src/util/mod.rs). 4. Separately, **P2P / dispute chat** is hydrated by the shared-key chat router (kind 14 `authors = [pub(K_sign)]`, plus legacy GiftWrap `#p` while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true). -5. This allows the client to reconstruct the current state of any active trade without needing a heavy local message database. +5. After **session restore** or **seed import / key reload**, `clear_session_chat_projection` clears stale in-memory chat cursors before relay re-hydrate (`src/ui/helpers/startup.rs`, `clear_runtime_session_state` in `src/ui/key_handler/async_tasks.rs`). See [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — "Session restore hydrate". +6. This allows the client to reconstruct the current state of any active trade without needing a heavy local message database. diff --git a/docs/MESSAGE_FLOW_AND_PROTOCOL.md b/docs/MESSAGE_FLOW_AND_PROTOCOL.md index 609d801c..8375763a 100644 --- a/docs/MESSAGE_FLOW_AND_PROTOCOL.md +++ b/docs/MESSAGE_FLOW_AND_PROTOCOL.md @@ -314,12 +314,14 @@ In addition to relay-driven trade DMs, Mostrix keeps a lightweight local transcr - **Path**: `~/.mostrix/orders_chat/.txt` - **Startup restore**: `load_user_order_chats_at_startup` restores cached chat into `AppState.order_chats` and seeds `order_chat_last_seen` from on-disk transcripts. Relay backfill is done once by the chat router on `TrackChatKey` after `track_startup_chats` (not a separate poll). +- **Session restore (no restart)**: after Settings → **Restore Session**, `clear_session_chat_projection` clears stale chat maps, DB rows are re-synced to the UI, then **`spawn_post_restore_hydrate`** rebuilds peer transcripts from relay in the background and re-runs `track_startup_chats` on `PostRestoreHydrateCompleted`. See [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — "Session restore hydrate". - **Live relay sync (User role)**: the **shared-key chat subscription router** (`listen_for_chat_messages` in `src/util/chat_listener.rs`) maintains a batched kind-14 subscription (`authors = [pub(K_sign)]`) over all active order chats and routes by outer author. While `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true, it also dual-reads legacy GiftWrap `#p` = ECDH pubkey. `track_startup_chats` seeds the active-order set at startup; the DM router tracks/untracks orders when the shared key becomes resolvable or the order hits a chat-terminal status ([`TERMINAL_DM_STATUSES`](../src/models.rs) — **`success` keeps chat live**). Dynamic tracks pass a hydrate `since` from the on-disk transcript max timestamp when present. Shared keys come from persisted `order_chat_shared_key_hex` when set, otherwise ECDH from local `trade_keys` + `counterparty_pubkey` (`src/util/chat_utils.rs`). - **Incremental merge**: `apply_user_order_chat_updates` in `src/ui/helpers/startup.rs`: - - **Skip own relay echoes**: each `OrderChatUpdate` carries `local_trade_pubkey`; messages whose decrypted `sender_pubkey` matches are ignored (same rule as admin chat and Mostro Mobile — avoids showing your send on both **You** and **Peer** after the optimistic local append on Enter). - - **Dedup**: relay self-echoes are skipped by `sender_pubkey == local_trade_pubkey`; peer dedup matches only existing **Peer** rows at the same `(timestamp, content)` (or same attachment / legacy placeholder) so an optimistic **You** line cannot hide a real counterparty message in the same second. - - **Peer-only from relay**: counterparty messages are stored as `UserChatSender::Peer`; local sends are appended as **You** in `handle_enter_user_order_chat` before the relay round-trip. - - Persists new entries with `save_order_chat_message` and advances per-order `order_chat_last_seen`. + - **Peer channel — own relay rows**: each `OrderChatUpdate` carries `local_trade_pubkey`. On **`UserChatChannel::Peer`**, messages from the local trade key are stored as **You** unless the inner event id is already known or an optimistic local **You** line exists at the same timestamp (live-send echo). This allows post-restore relay rebuild to include the user's own history when the on-disk transcript was empty. + - **Solver channel — own relay rows**: still skips all messages whose decrypted `sender_pubkey` matches `local_trade_pubkey` (admin/solver chat convention). + - **Dedup**: inner-event id guards prevent double-writes; peer content dedup matches only existing **Peer** rows at the same `(timestamp, content)` (or same attachment / legacy placeholder) so an optimistic **You** line cannot hide a real counterparty message in the same second. + - **Peer-only from relay (counterparty)**: counterparty messages are stored as `UserChatSender::Peer`; local sends are appended as **You** in `handle_enter_user_order_chat` before the relay round-trip. + - Persists new entries with `save_order_chat_message` / `rewrite_order_chat_messages` and advances per-order `order_chat_last_seen`. Inner event ids are recorded only after durable transcript save succeeds. - **Attachments (receive + save)**: `image_encrypted` / `file_encrypted` JSON (Mostro Mobile Encrypted File Messaging) is parsed in `apply_user_order_chat_updates` via `try_parse_attachment_message`. Attachment rows show yellow placeholder lines in the chat pane; the block title includes a file count when non-zero; a transient toast notifies on new **peer** files. **Ctrl+S** on My Trades opens `UiMode::UserSaveAttachmentPopup` (pinned `order_id` + list index). Saving downloads from Blossom and decrypts with the attachment key when present, otherwise derives the 32-byte shared secret via `order_chat_decryption_key_bytes` (from `order_chat_shared_key_hex` or ECDH). Files land in `~/.mostrix/downloads/_`. - **Attachments (send)**: **Ctrl+O** on My Trades opens `UiMode::UserSendAttachmentPicker` (`src/ui/send_attachment_picker.rs`, `ratatui-explorer`) filtered to allowed extensions; **Enter** enqueues `SendOrderAttachmentJob::FromPath`. **Ctrl+Shift+O** retries with `RetryPrepared` when `pending_order_attachment_sends` holds the order. Pipeline in `src/util/send_attachment.rs`: 1. **Validate** local path — `validate_attachment_file` in `src/util/file_validation.rs` (max **25 MB**, extensions `jpg`/`jpeg`/`png`/`pdf`/`mp4`/`mov`/`avi`/`doc`/`docx`, PDF magic-byte check). Images must yield non-zero **width/height** via `read_image_dimensions` (PNG IHDR / JPEG SOF) — required for mobile `image_encrypted` JSON. diff --git a/docs/README.md b/docs/README.md index 8a367110..e33da107 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,9 +4,10 @@ Index of architecture and feature guides for the Mostrix TUI client. The [root R ## Core runtime & data -- **Startup & Configuration**: [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — Boot sequence, settings (`blossom_servers`), background tasks, DM router wiring, reconnect; main loop **drains save/send-attachment and operation-result channels before draw** (150 ms refresh) -- **DM listener & router**: [DM_LISTENER_FLOW.md](DM_LISTENER_FLOW.md) — `listen_for_order_messages`; transport-aware subscribe (`filter_protocol_dm_from_mostro`) and event gate (`transport.event_kind()`); outbound `send_dm` uses `wrap_message_with`; inbound parse uses `unwrap_incoming` -- **Message Flow & Protocol**: [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — How Mostrix talks to Mostro over Nostr (orders, protocol DMs, restarts, cooperative cancel / `TradeClosed`); **protocol v2** dual transport (`protocol_version` → subscribe, `wrap_message_with`, `unwrap_incoming` — see [Protocol v2 (NIP-44)](#protocol-v2-nip-44--protocol-dms-complete)); **maker bond** (`send_new_order` → `PayBondInvoice` / `PaymentRequestRequired`, deferred `NewOrder` after payment); **My Trades user order chat** relay sync, own-message echo skip, attachment receive/save, **outbound send** (Ctrl+O picker, trade-key Blossom auth, mobile-compatible wire JSON, upload-then-send retry / **Ctrl+Shift+O**, `pending_order_attachment_sends`), **JSON transcript persistence** (Ctrl+S after restart) +- **Startup & Configuration**: [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — Boot sequence, settings (`blossom_servers`), background tasks, DM router wiring, reconnect; **session restore hydrate** (trade-DM + peer-chat rebuild without restart); main loop **drains save/send-attachment and operation-result channels before draw** (150 ms refresh) +- **Session restore acceptance**: [RESTORE_SESSION_ACCEPTANCE.md](RESTORE_SESSION_ACCEPTANCE.md) — post-restore hydrate criteria, automated proofs, manual smoke checklist (step 6) +- **DM listener & router**: [DM_LISTENER_FLOW.md](DM_LISTENER_FLOW.md) — `listen_for_order_messages`; startup and **post-restore** trade-DM replay (`replay_active_trade_dms`, `trade_dm_replay_fetch_filter`); transport-aware subscribe (`filter_protocol_dm_from_mostro`) and event gate (`transport.event_kind()`); outbound `send_dm` uses `wrap_message_with`; inbound parse uses `unwrap_incoming` +- **Message Flow & Protocol**: [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — How Mostrix talks to Mostro over Nostr (orders, protocol DMs, restarts, cooperative cancel / `TradeClosed`); **protocol v2** dual transport (`protocol_version` → subscribe, `wrap_message_with`, `unwrap_incoming` — see [Protocol v2 (NIP-44)](#protocol-v2-nip-44--protocol-dms-complete)); **maker bond** (`send_new_order` → `PayBondInvoice` / `PaymentRequestRequired`, deferred `NewOrder` after payment); **My Trades user order chat** relay sync, peer-channel echo handling, **post-restore peer transcript rebuild**, attachment receive/save, **outbound send** (Ctrl+O picker, trade-key Blossom auth, mobile-compatible wire JSON, upload-then-send retry / **Ctrl+Shift+O**, `pending_order_attachment_sends`), **JSON transcript persistence** (Ctrl+S after restart) - **Kind-14 P2P chat acceptance**: [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md) — mostrix#102 criteria mapped to automated tests + optional live smoke (closes the gift-wrap apocalypse migration) - **PoW & outbound events**: [POW_AND_OUTBOUND_EVENTS.md](POW_AND_OUTBOUND_EVENTS.md) — Instance `pow` and optional `pow_first_contact` (kind 38385), [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs), [`send_dm`](../src/util/dm_utils/mod.rs) → [`wrap_message_with`](../src/util/mod.rs) (GiftWrap outer PoW or v2 signed kind-14) - **Database**: [DATABASE.md](DATABASE.md) — SQLite schema, `orders` / `users` / `admin_disputes`, migrations; **relay → SQLite reconcile** for terminal order statuses (`relay_order_db_reconcile.rs`) @@ -14,7 +15,7 @@ Index of architecture and feature guides for the Mostrix TUI client. The [root R ## UI & order flows -- **TUI Interface**: [TUI_INTERFACE.md](TUI_INTERFACE.md) — Navigation, modes, state; **Orders** id-based selection + stateful table scroll; **Create New Order** (sectioned form, live preview receipt, searchable currency picker from instance or `currencies.rs`, silent draft persistence, inline validation); **My Trades** (`user_my_trades_interactive`, scroll, receive attachments + Ctrl+S save, **Ctrl+O** send picker + **Ctrl+Shift+O** retry, `order_chat_static` vs live projection); Messages timeline (`StepPendingOrder` = no highlighted column while `Pending` / `WaitingTakerBond` / `WaitingMakerBond`) +- **TUI Interface**: [TUI_INTERFACE.md](TUI_INTERFACE.md) — Navigation, modes, state; **Orders** id-based selection + stateful table scroll; **Create New Order** (sectioned form, live preview receipt, searchable currency picker from instance or `currencies.rs`, silent draft persistence, inline validation); **Settings** → **Restore Session** (post-restore hydrate without restart); **My Trades** (`user_my_trades_interactive`, scroll, receive attachments + Ctrl+S save, **Ctrl+O** send picker + **Ctrl+Shift+O** retry, `order_chat_static` vs live projection); Messages timeline (`StepPendingOrder` = no highlighted column while `Pending` / `WaitingTakerBond` / `WaitingMakerBond`) - **UI constants** (`src/ui/constants.rs`): Shared copy (footers, help, **`StepLabel`** for the Messages tab buy/sell timeline) - **Buy order flow (spec)**: [buy order flow.md](buy%20order%20flow.md) — Phase 1.5+ taker bond and Phase 5+ maker bond (`PayBondInvoice` / `WaitingTakerBond` / `WaitingMakerBond`) - **Sell order flow (spec)**: [sell order flow.md](sell%20order%20flow.md) — Phase 1.5+ taker bond and Phase 5+ maker bond (`PayBondInvoice` / `WaitingTakerBond` / `WaitingMakerBond`) diff --git a/docs/RESTORE_SESSION_ACCEPTANCE.md b/docs/RESTORE_SESSION_ACCEPTANCE.md new file mode 100644 index 00000000..c119c6a4 --- /dev/null +++ b/docs/RESTORE_SESSION_ACCEPTANCE.md @@ -0,0 +1,105 @@ +# Session restore hydrate — acceptance + +Tracks **step 6** of the post-restore hydrate plan (steps 1–5 shipped in PRs +[#156](https://github.com/MostroP2P/mostrix/pull/156)–[#158](https://github.com/MostroP2P/mostrix/pull/158); +orchestrator in [#159](https://github.com/MostroP2P/mostrix/pull/159)). + +**Goal:** after seed import or **Settings → Restore Session**, the user sees +restored trades on **Messages** and **My Trades** (including peer chat history) +**without** restarting Mostrix. + +Architecture overview: [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — +"Session restore hydrate". + +## Criteria → evidence + +| Criterion | Automated proof | Manual | +|---|---|---| +| DB status → Messages-tab `Action` after restore | `history_action_for_db_order_tests` in `src/ui/helpers/startup.rs` | Restore an in-progress buy/sell; Messages column matches trade phase | +| Trade-DM replay uses catch-up when no `last_seen_dm_ts` | `trade_dm_replay_no_cursor_*` in `src/util/dm_utils/mod.rs` | Post-wipe restore: Messages tab not empty for active orders | +| Trade-DM replay uses `UntrackedFallback` without live subscription | `trade_dm_replay_uses_untracked_fallback_*` in `src/util/dm_utils/mod.rs` | Same as above immediately after restore (before listener re-track) | +| Stale chat cursors cleared before hydrate | `clear_session_chat_projection_tests` in `src/ui/helpers/startup.rs` | Restore after seed import: own peer lines visible (not dropped) | +| Peer transcript maps You/Peer and sorts by time | `transcript_maps_you_and_peer_and_sorts_by_timestamp` in `src/ui/helpers/startup.rs` | My Trades chat shows both sides of history | +| Inner event ids recorded only after transcript save | Code path in `rebuild_peer_order_chat_transcript` (after `rewrite_order_chat_messages`) | Failed disk write would allow retry (hard to trigger manually) | +| Orchestrator runs trade-DM + peer chat in parallel | `hydrate_after_session_restore` + `tokio::join!` in `src/ui/helpers/startup.rs` | Log shows both summary lines within seconds | +| Completion always emitted (incl. zero peer orders) | `hydrate_with_no_eligible_work_returns_empty_report`, `spawn_post_restore_hydrate_emits_completion_with_empty_peer_orders` | Restore identity with no chat-eligible orders — app stays usable | +| `track_startup_chats` re-runs after hydrate | `PostRestoreHydrateCompleted` handler in `src/main.rs` | New peer messages arrive live after restore without restart | +| `SessionRestored` (not `Info`) triggers hydrate | `restore_completion_result` tests in `src/util/order_utils/execute_restore.rs` | N/A (regression guard) | + +## Out of scope (this acceptance pass) + +- Admin dispute chat transcript rebuild after restore +- Solver-channel (`UserChatChannel::Solver`) post-restore relay rebuild +- First-launch restore UX beyond the same `SessionRestored` pipeline + +## Prerequisites (manual smoke) + +1. **Live Mostro** instance and reachable relays (same as normal trading). +2. **Test identity** with at least one **active** order that has: + - Trade protocol DMs on relay (Messages tab history), and + - Peer order chat messages on relay (My Trades chat panel). +3. Optional second scenario: identity with active orders but **no** counterparty + chat key yet (zero peer-chat hydrate) — for criterion #8. + +Suggested log level for manual runs: `RUST_LOG=info` (or `mostrix=info`). + +## Manual smoke checklist + +Run each scenario **without** quitting Mostrix between restore and verification. + +### A — Full restore (primary) + +| # | Step | Expected | +|---|------|----------| +| A1 | Note current **Messages** step and **My Trades** peer chat transcript | Baseline for comparison | +| A2 | **Settings** → **Restore Session** → confirm **Yes** | Success popup; no crash | +| A3 | Open **My Trades** | Restored order(s) listed | +| A4 | Open **Messages** | Restored order(s) listed; timeline step matches trade phase (not stuck at empty / wrong column) | +| A5 | Wait ~5–30 s (relay hydrate) | No duplicate bond/invoice popups | +| A6 | **My Trades** → select order → peer chat panel | **You** and **Peer** lines from history (not empty; not all **Peer**) | +| A7 | Send a new chat line → Enter | Appears as **You** once; relay echo does **not** duplicate | +| A8 | Receive a counterparty message (or simulate from other client) | Appears as **Peer**; UI responsive | + +**Log grep (optional):** + +```text +Post-restore trade DM replay: +Post-restore peer chat rebuild: +``` + +Both lines should appear after A2. On `PostRestoreHydrateCompleted`, chat router +re-tracks without restart. + +### B — Edge: no peer-chat-eligible orders + +| # | Step | Expected | +|---|------|----------| +| B1 | Restore an identity whose active orders lack shared key + counterparty pubkey | Success popup | +| B2 | Use app normally (Orders / Messages tabs) | No hang; no fatal error | +| B3 | Check logs | `Post-restore peer chat rebuild: attempted=0` (or skipped orders); hydrate still completes | + +### C — Seed import + key reload (cursor hygiene) + +| # | Step | Expected | +|---|------|----------| +| C1 | Import seed / regenerate keys (staged wipe flow) | App reloads session | +| C2 | Restore or continue with active trades | Peer chat and Messages hydrate; no stale identity cursors | + +## Sign-off + +| Field | Value | +|-------|-------| +| Mostrix version / commit | | +| Mostro instance | | +| Date | | +| Tester | | +| A (full restore) | ☐ pass ☐ fail | +| B (zero peer chat) | ☐ pass ☐ skip ☐ fail | +| C (seed import) | ☐ pass ☐ skip ☐ fail | +| Notes | | + +## Related + +- [DM_LISTENER_FLOW.md](DM_LISTENER_FLOW.md) — post-restore trade-DM replay (§4) +- [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — peer chat echo rules +- [KEY_MANAGEMENT.md](KEY_MANAGEMENT.md) — `clear_session_chat_projection` on key reload diff --git a/docs/STARTUP_AND_CONFIG.md b/docs/STARTUP_AND_CONFIG.md index 23523676..c9a2c24b 100644 --- a/docs/STARTUP_AND_CONFIG.md +++ b/docs/STARTUP_AND_CONFIG.md @@ -214,7 +214,38 @@ For **User** role, Mostrix restores peer-to-peer order chat alongside trade DMs: - Cached transcripts live under `~/.mostrix/orders_chat/.txt` and are loaded into `AppState.order_chats` by `load_user_order_chats_at_startup`. - **Attachment rows in transcripts** are stored as **JSON** (`image_encrypted` / `file_encrypted` via `serialize_attachment_for_transcript`) so **Ctrl+S** and file counts work immediately after restart; legacy `[Image: … - Ctrl+S to save]` lines are hydrated in memory when relay returns the same attachment at the same timestamp. - Disk restore via `load_user_order_chats_at_startup` seeds `AppState.order_chats` and `order_chat_last_seen`. Relay history is hydrated once by the **shared-key chat subscription router** when `track_startup_chats` emits `TrackChatKey` — no separate startup poll and no timed polling. -- `apply_user_order_chat_updates` skips relay echoes of the local trade pubkey; peer dedup is scoped to existing **Peer** rows so optimistic **You** sends are not mirrored as **Peer** and do not suppress unrelated peer text at the same timestamp. See [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — "User order chat local cache". +- `apply_user_order_chat_updates` on the **peer** channel maps relay rows from the local trade key to **You** unless the inner event id is already known or an optimistic **You** line exists at the same timestamp (live-send echo). The **solver** channel still skips all local-trade-key rows. Peer dedup matches only existing **Peer** rows at the same `(timestamp, content)` (or same attachment / legacy placeholder) so an optimistic **You** line cannot hide a real counterparty message in the same second. See [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — "User order chat local cache". +- After **session restore** (Settings → Restore Session, without restart), peer transcripts are rebuilt from relay in the background and `track_startup_chats` is re-emitted when hydrate completes — see [Session restore hydrate](#session-restore-hydrate-without-restart) below. + +### Session restore hydrate (without restart) + +When the user confirms **Restore Session** (`execute_restore_session` → `Action::RestoreSession`), Mostro returns the identity's active orders and disputes; Mostrix rebuilds SQLite and must re-hydrate in-memory UI state **without** a full app restart. + +**Entry**: Settings → **Restore Session** (`UiMode::ConfirmRestoreSession`) or first-launch restore path in `main.rs`. Success MUST emit [`OperationResult::SessionRestored`](../src/ui/orders.rs) (not plain `Info`) so `apply_order_result` runs the hydrate pipeline (`restore_completion_result` in `src/util/order_utils/execute_restore.rs`). + +**Synchronous steps** (`apply_order_result` on `SessionRestored`, User role): + +1. **`clear_session_chat_projection`** — wipe in-memory peer/solver chat transcripts, `order_chat_last_seen` / `user_dispute_chat_last_seen`, maker-book cache, attachment send state, and related maps. Stale cursors from the prior identity would otherwise bound relay fetches incorrectly; empty transcripts plus old echo-skip rules would drop the user's own relay rows during rebuild. Also called from **`clear_runtime_session_state`** after seed import / key reload (`src/ui/key_handler/async_tasks.rs`). +2. **DB → UI projection** — `refresh_my_trades_maker_book_cache` + `sync_user_order_history_messages_from_db` (same paths as cold startup; `history_action_for_db_order` maps DB status → Messages-tab `Action`). +3. **Restore success popup** — `handle_operation_result` shows the `SessionRestored` message. + +**Background hydrate** (non-blocking; unified orchestrator in `src/ui/helpers/startup.rs`): + +- **`spawn_post_restore_hydrate`** runs **`hydrate_after_session_restore`** in a `tokio::spawn` task. Trade-DM replay and peer-chat rebuild run **in parallel** (`tokio::join!`). +- **Trade protocol DMs (Messages tab, pipe A)** — `prepare_post_restore_trade_dm_replay` seeds `active_order_trade_indices` / `startup_popup_floor_ts`, then **`replay_active_trade_dms`** (`src/util/dm_utils/mod.rs`) fetches per active order. Filter: **`trade_dm_replay_fetch_filter`** — when `last_seen_dm_ts` is missing (fresh restore / post-wipe), **no `since`** (limit-only catch-up, mirrors `DmSubscriptionMode::StartupCatchUp`); when a cursor exists, `since` + 12h lookback + GiftWrap envelope skew. Uses **`UntrackedFallback`** when the DM router has no live subscription yet. Updates `AppState.messages` in place (`notify: false`). +- **Peer order chat (My Trades chat panel, pipe B)** — **`rebuild_peer_order_chats_after_restore`** relay-fetches shared-key history per active order (`fetch_chat_messages_for_shared_key`), dedupes by inner event id, maps **You** / **Peer** from inner signer, sorts by timestamp, persists via **`rewrite_order_chat_messages`**, then records inner ids only after a successful save. +- **Completion** — always emits silent **`OperationResult::PostRestoreHydrateCompleted { report: RestoreHydrateReport }`** (aggregates `TradeDmReplaySummary` + `PeerOrderChatRestoreSummary` + hydrated order ids), even when there are zero peer-chat-eligible orders. + +**Main loop on `PostRestoreHydrateCompleted`**: + +1. **`apply_restored_peer_order_chats_from_disk`** — load rebuilt `orders_chat/.txt` into `AppState.order_chats` and seed `order_chat_last_seen`. +2. **`track_startup_chats`** — re-seed the shared-key chat router so live subscriptions cover restored orders. + +**Out of scope (current restore hydrate)**: admin dispute chat transcript rebuild; solver-channel post-restore relay rebuild (solver channel still uses blanket local-trade-key skip in `apply_user_order_chat_updates`). + +**Acceptance:** automated proofs + manual smoke checklist — [RESTORE_SESSION_ACCEPTANCE.md](RESTORE_SESSION_ACCEPTANCE.md). + +**Source**: `src/main.rs` (`apply_order_result`), `src/ui/helpers/startup.rs` (`clear_session_chat_projection`, `hydrate_after_session_restore`, `spawn_post_restore_hydrate`, `RestoreHydrateReport`), `src/util/dm_utils/mod.rs` (`replay_active_trade_dms`, `trade_dm_replay_fetch_filter`), `src/util/order_utils/execute_restore.rs`. ## Main Event Loop diff --git a/docs/TUI_INTERFACE.md b/docs/TUI_INTERFACE.md index 32a54cb6..82b5b64b 100644 --- a/docs/TUI_INTERFACE.md +++ b/docs/TUI_INTERFACE.md @@ -85,7 +85,7 @@ Focused on trading and order management. - **Orders**: View the global order book (persistent `TableState` scrolls with ↑↓; shared vertical scrollbar confined to data rows). - **My Trades**: Manage active trades. - **Messages**: Direct messages for trade coordination. -- **Settings**: Local configuration. **User mode**: key rotation via **Generate New Keys** and mnemonic backup prompts; **Set Lightning Address (buyer)** / **Clear Lightning Address** — optional `user@domain.com` stored in `settings.toml`; confirm-save fetches LNURL metadata (`payRequest`) before persisting (see `src/util/ln_address.rs`, `spawn_verify_and_save_ln_address_task`). **Admin mode**: **Change Admin Key** / **Add Dispute Solver** (no Generate New Keys — admin must use the Mostro daemon nsec). The visible menu and **Enter** routing share **`ADMIN_SETTINGS`** / **`USER_SETTINGS`** in `src/ui/tabs/settings_tab.rs` (`SettingsMenuAction` + label per row; **`settings_action_for_index`**). +- **Settings**: Local configuration. **User mode**: key rotation via **Generate New Keys** and mnemonic backup prompts; **Restore Session** (rebuild SQLite from Mostro + background hydrate of Messages tab trade DMs and My Trades peer chat without restart — see [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md)); **Set Lightning Address (buyer)** / **Clear Lightning Address** — optional `user@domain.com` stored in `settings.toml`; confirm-save fetches LNURL metadata (`payRequest`) before persisting (see `src/util/ln_address.rs`, `spawn_verify_and_save_ln_address_task`). **Admin mode**: **Change Admin Key** / **Add Dispute Solver** (no Generate New Keys — admin must use the Mostro daemon nsec). The visible menu and **Enter** routing share **`ADMIN_SETTINGS`** / **`USER_SETTINGS`** in `src/ui/tabs/settings_tab.rs` (`SettingsMenuAction` + label per row; **`settings_action_for_index`**). - **Create New Order**: Sectioned order form with live preview, searchable currency picker (instance `fiat_currencies_accepted` or bundled ISO list), and silent draft persistence when switching tabs. ### Admin Role @@ -517,7 +517,7 @@ Active admin-chat transport is kind 14 (`K_sign` / `K_conv`). GiftWrap is inboun - Published to relays without blocking the main UI thread. - **Receiving messages**: - - The shared-key chat subscription router (`listen_for_chat_messages`) delivers messages live over a batched subscription (kind 14 always; GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true). Disputes are tracked via `track_dispute_chat` when taken (with a party+admin inner-signer allow-list) and re-tracked by `track_startup_chats` at startup/reconnect. History is hydrated once per key on track. + - The shared-key chat subscription router (`listen_for_chat_messages`) delivers messages live over a batched subscription (kind 14 always; GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true). Disputes are tracked via `track_dispute_chat` when taken (with a party+admin inner-signer allow-list) and re-tracked by `track_startup_chats` at startup, reconnect, and after **session restore** (`PostRestoreHydrateCompleted`). History is hydrated once per key on track. - For each in-progress dispute, the fetch: - Rebuilds buyer/seller shared `Keys` from the stored hex. - Fetches history with kind-14 `authors = [pub(K_sign)]`, plus a legacy `kind: 1059` `#p` query while dual-read is on (7-day rolling window; GiftWrap `created_at` is randomized so that query keeps the wide floor). diff --git a/src/main.rs b/src/main.rs index d9e59366..b9e77515 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,9 +14,8 @@ use crate::ui::helpers::{ 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, + refresh_my_trades_maker_book_cache, spawn_post_restore_hydrate, + 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, @@ -77,9 +76,9 @@ 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`]. +/// Messages/My Trades rows, and spawns background post-restore hydrate (trade DMs + peer chat). +/// [`OperationResult::PostRestoreHydrateCompleted`] loads rebuilt peer transcripts from disk +/// and re-runs [`track_startup_chats`]. async fn apply_order_result( pool: &SqlitePool, app: &mut AppState, @@ -89,11 +88,8 @@ async fn apply_order_result( message_notification_tx: &UnboundedSender, order_result_tx: &UnboundedSender, ) { - if matches!(&result, OperationResult::PostRestoreTradeDmReplayCompleted) { - return; - } - if let OperationResult::PostRestorePeerChatReplayCompleted { order_ids } = &result { - apply_restored_peer_order_chats_from_disk(app, order_ids); + if let OperationResult::PostRestoreHydrateCompleted { report } = &result { + apply_restored_peer_order_chats_from_disk(app, &report.peer_hydrated_order_ids); track_startup_chats(pool, app).await; return; } @@ -126,8 +122,7 @@ async fn apply_order_result( if !matches!( result, OperationResult::MyTradesMakerBookChanged - | OperationResult::PostRestoreTradeDmReplayCompleted - | OperationResult::PostRestorePeerChatReplayCompleted { .. } + | OperationResult::PostRestoreHydrateCompleted { .. } ) { handle_operation_result(result, app); } @@ -136,21 +131,15 @@ async fn apply_order_result( } if is_session_restored && app.user_role == UserRole::User { - if let Some(job) = prepare_post_restore_trade_dm_replay(pool, app).await { - spawn_post_restore_trade_dm_replay( - job, - pool.clone(), - client.clone(), - mostro_pubkey, - message_notification_tx.clone(), - order_result_tx.clone(), - ); - } + let trade_dm_job = prepare_post_restore_trade_dm_replay(pool, app).await; let peer_order_ids = active_peer_chat_order_ids_for_restore(pool).await; - spawn_post_restore_peer_chat_hydrate( + spawn_post_restore_hydrate( pool.clone(), client.clone(), + mostro_pubkey, + trade_dm_job, peer_order_ids, + message_notification_tx.clone(), order_result_tx.clone(), ); } diff --git a/src/ui/helpers/mod.rs b/src/ui/helpers/mod.rs index 80fd48f3..148461ce 100644 --- a/src/ui/helpers/mod.rs +++ b/src/ui/helpers/mod.rs @@ -67,7 +67,6 @@ pub use startup::{ hydrate_app_admin_keys_from_privkey, load_admin_disputes_at_startup, 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, + refresh_my_trades_maker_book_cache, spawn_post_restore_hydrate, + sync_user_order_history_messages_from_db, track_startup_chats, RestoreHydrateReport, }; diff --git a/src/ui/helpers/startup.rs b/src/ui/helpers/startup.rs index 1130610e..0fffe91d 100644 --- a/src/ui/helpers/startup.rs +++ b/src/ui/helpers/startup.rs @@ -26,6 +26,7 @@ use crate::util::{ 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, + TradeDmReplaySummary, }; use super::attachments::{ @@ -162,7 +163,7 @@ pub async fn load_admin_disputes_at_startup(pool: &SqlitePool, app: &mut AppStat /// 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. After /// [`OperationResult::SessionRestored`], call again once peer-chat transcripts are on disk -/// ([`OperationResult::PostRestorePeerChatReplayCompleted`]) so live subscriptions cover rebuilt orders. +/// ([`OperationResult::PostRestoreHydrateCompleted`]) so live subscriptions cover rebuilt orders. pub async fn track_startup_chats(pool: &SqlitePool, app: &AppState) { match app.user_role { UserRole::User => { @@ -282,7 +283,7 @@ pub async fn track_startup_chats(pool: &SqlitePool, app: &AppState) { /// 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). After /// [`OperationResult::SessionRestored`], peer transcripts are rebuilt from relay via -/// [`spawn_post_restore_peer_chat_hydrate`] instead of relying on this path alone. +/// [`spawn_post_restore_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; @@ -640,53 +641,142 @@ pub async fn prepare_post_restore_trade_dm_replay( }) } -/// Spawn relay fetch + hydrate without blocking the UI loop; completion is reported on -/// `order_result_tx` (silent [`OperationResult::PostRestoreTradeDmReplayCompleted`]). -pub fn spawn_post_restore_trade_dm_replay( +/// Run trade-DM relay replay for one post-restore job (awaitable). +async fn run_post_restore_trade_dm_replay( job: PostRestoreTradeDmReplayJob, + pool: &SqlitePool, + client: &Client, + mostro_pubkey: PublicKey, + message_notification_tx: UnboundedSender, +) -> TradeDmReplaySummary { + let user = match User::get(pool).await { + Ok(u) => u, + Err(e) => { + log::warn!("Post-restore: failed to load user for trade DM replay: {e}"); + return TradeDmReplaySummary::default(); + } + }; + + replay_active_trade_dms( + client, + mostro_pubkey, + job.transport, + pool, + &user, + job.messages, + job.pending_notifications, + message_notification_tx, + job.active_order_trade_indices, + job.dropped_user_history_order_ids, + job.startup_active_orders, + job.order_last_seen_dm_ts, + ) + .await +} + +/// Aggregate outcome of post-restore relay hydrates (trade DMs + peer order chat). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RestoreHydrateReport { + /// `None` when no active orders were eligible for trade-DM replay. + pub trade_dm: Option, + pub peer_chat: PeerOrderChatRestoreSummary, + /// Order ids whose peer transcripts were persisted during hydrate. + pub peer_hydrated_order_ids: Vec, +} + +impl RestoreHydrateReport { + /// Log a single summary line for operators. + pub fn log_summary(&self) { + if let Some(trade_dm) = &self.trade_dm { + log::info!( + "Post-restore trade DM replay: attempted={} hydrated={} empty={} fetch_failed={} parse_failed={} skipped_no_keys={}", + trade_dm.attempted, + trade_dm.hydrated, + trade_dm.empty, + trade_dm.fetch_failed, + trade_dm.parse_failed, + trade_dm.skipped_no_keys + ); + } else { + log::info!("Post-restore trade DM replay: skipped (no eligible orders)"); + } + log::info!( + "Post-restore peer chat rebuild: attempted={} hydrated={} empty={} fetch_failed={} skipped_no_key={}", + self.peer_chat.attempted, + self.peer_chat.hydrated, + self.peer_chat.empty, + self.peer_chat.fetch_failed, + self.peer_chat.skipped_no_key + ); + } +} + +/// Awaitable post-restore hydrate: trade-DM replay and peer-chat rebuild run in parallel. +pub async fn hydrate_after_session_restore( + client: &Client, + pool: &SqlitePool, + mostro_pubkey: PublicKey, + trade_dm_job: Option, + peer_order_ids: Vec, + message_notification_tx: UnboundedSender, +) -> RestoreHydrateReport { + let trade_dm_fut = async { + match trade_dm_job { + Some(job) => Some( + run_post_restore_trade_dm_replay( + job, + pool, + client, + mostro_pubkey, + message_notification_tx, + ) + .await, + ), + None => None, + } + }; + let peer_chat_fut = async { + if peer_order_ids.is_empty() { + (PeerOrderChatRestoreSummary::default(), Vec::new()) + } else { + rebuild_peer_order_chats_after_restore(client, pool, &peer_order_ids).await + } + }; + + let (trade_dm, (peer_chat, peer_hydrated_order_ids)) = + tokio::join!(trade_dm_fut, peer_chat_fut); + + RestoreHydrateReport { + trade_dm, + peer_chat, + peer_hydrated_order_ids, + } +} + +/// Spawn post-restore hydrate without blocking the UI loop; completion is reported on +/// `order_result_tx` as [`OperationResult::PostRestoreHydrateCompleted`] (always sent, +/// including when there are no peer orders to rebuild). +pub fn spawn_post_restore_hydrate( pool: SqlitePool, client: Client, mostro_pubkey: PublicKey, + trade_dm_job: Option, + peer_order_ids: Vec, message_notification_tx: UnboundedSender, order_result_tx: UnboundedSender, ) { tokio::spawn(async move { - let user = match User::get(&pool).await { - Ok(u) => u, - Err(e) => { - log::warn!("Post-restore: failed to load user for trade DM replay: {e}"); - let _ = order_result_tx.send(OperationResult::PostRestoreTradeDmReplayCompleted); - return; - } - }; - - let summary = replay_active_trade_dms( + let report = hydrate_after_session_restore( &client, - mostro_pubkey, - job.transport, &pool, - &user, - job.messages, - job.pending_notifications, + mostro_pubkey, + trade_dm_job, + peer_order_ids, message_notification_tx, - job.active_order_trade_indices, - job.dropped_user_history_order_ids, - job.startup_active_orders, - job.order_last_seen_dm_ts, ) .await; - - log::info!( - "Post-restore trade DM replay: attempted={} hydrated={} empty={} fetch_failed={} parse_failed={} skipped_no_keys={}", - summary.attempted, - summary.hydrated, - summary.empty, - summary.fetch_failed, - summary.parse_failed, - summary.skipped_no_keys - ); - - let _ = order_result_tx.send(OperationResult::PostRestoreTradeDmReplayCompleted); + report.log_summary(); + let _ = order_result_tx.send(OperationResult::PostRestoreHydrateCompleted { report }); }); } @@ -879,36 +969,6 @@ pub async fn active_peer_chat_order_ids_for_restore(pool: &SqlitePool) -> 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** @@ -1351,6 +1411,100 @@ mod peer_order_chat_restore_tests { } } +#[cfg(test)] +mod restore_hydrate_orchestrator_tests { + use super::{hydrate_after_session_restore, PeerOrderChatRestoreSummary, RestoreHydrateReport}; + use crate::util::TradeDmReplaySummary; + use nostr_sdk::prelude::{Client, Keys}; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn restore_hydrate_report_default_is_empty() { + let report = RestoreHydrateReport::default(); + assert!(report.trade_dm.is_none()); + assert_eq!(report.peer_chat, PeerOrderChatRestoreSummary::default()); + assert!(report.peer_hydrated_order_ids.is_empty()); + } + + #[test] + fn restore_hydrate_report_log_summary_does_not_panic() { + RestoreHydrateReport { + trade_dm: Some(TradeDmReplaySummary { + attempted: 1, + hydrated: 1, + ..TradeDmReplaySummary::default() + }), + peer_chat: PeerOrderChatRestoreSummary { + attempted: 2, + hydrated: 1, + ..PeerOrderChatRestoreSummary::default() + }, + peer_hydrated_order_ids: vec!["order-1".to_string()], + } + .log_summary(); + } + + #[tokio::test] + async fn hydrate_with_no_eligible_work_returns_empty_report() { + let client = Client::new(); + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory pool"); + let (tx, _rx) = unbounded_channel(); + + let report = hydrate_after_session_restore( + &client, + &pool, + Keys::generate().public_key(), + None, + vec![], + tx, + ) + .await; + + assert!(report.trade_dm.is_none()); + assert_eq!(report.peer_chat, PeerOrderChatRestoreSummary::default()); + assert!(report.peer_hydrated_order_ids.is_empty()); + } + + #[tokio::test] + async fn spawn_post_restore_hydrate_emits_completion_with_empty_peer_orders() { + use super::spawn_post_restore_hydrate; + use crate::ui::OperationResult; + use std::time::Duration; + + let client = Client::new(); + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory pool"); + let (notify_tx, _notify_rx) = unbounded_channel(); + let (result_tx, mut result_rx) = unbounded_channel(); + + spawn_post_restore_hydrate( + pool, + client, + Keys::generate().public_key(), + None, + vec![], + notify_tx, + result_tx, + ); + + let result = tokio::time::timeout(Duration::from_secs(5), result_rx.recv()) + .await + .expect("timed out waiting for post-restore hydrate") + .expect("order result channel closed"); + + match result { + OperationResult::PostRestoreHydrateCompleted { report } => { + assert!(report.trade_dm.is_none()); + assert!(report.peer_hydrated_order_ids.is_empty()); + } + other => panic!("expected PostRestoreHydrateCompleted, got {other:?}"), + } + } +} + #[cfg(test)] mod clear_session_chat_projection_tests { use super::clear_session_chat_projection; diff --git a/src/ui/operation_result.rs b/src/ui/operation_result.rs index 6c28f045..59536c60 100644 --- a/src/ui/operation_result.rs +++ b/src/ui/operation_result.rs @@ -302,8 +302,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) | OperationResult::OrderHistoryDeleted { .. } | OperationResult::AdminDisputeDeleted { .. } | OperationResult::MyTradesMakerBookChanged - | OperationResult::PostRestoreTradeDmReplayCompleted - | OperationResult::PostRestorePeerChatReplayCompleted { .. } + | OperationResult::PostRestoreHydrateCompleted { .. } | OperationResult::OpenInvoicePopup { .. } | OperationResult::OrderChatAttachmentSent { .. } | OperationResult::OrderChatAttachmentSendFailed { .. } @@ -494,8 +493,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) f.render_widget(paragraph, inner); } OperationResult::MyTradesMakerBookChanged - | OperationResult::PostRestoreTradeDmReplayCompleted - | OperationResult::PostRestorePeerChatReplayCompleted { .. } + | OperationResult::PostRestoreHydrateCompleted { .. } | OperationResult::OpenInvoicePopup { .. } | OperationResult::OrderChatAttachmentSent { .. } | OperationResult::OrderChatAttachmentSendFailed { .. } diff --git a/src/ui/orders.rs b/src/ui/orders.rs index f7e1d89e..0d72af1d 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -194,13 +194,10 @@ pub enum OperationResult { }, /// Rebuild [`crate::ui::AppState::my_trades_maker_book`] from SQLite (no UI popup). MyTradesMakerBookChanged, - /// Background post-restore trade-DM replay finished (silent; `messages` already updated). - PostRestoreTradeDmReplayCompleted, - /// 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, + /// Background post-restore hydrate finished (silent). The main loop loads peer transcripts + /// from disk and re-emits [`crate::ui::helpers::track_startup_chats`]. + PostRestoreHydrateCompleted { + report: crate::ui::helpers::RestoreHydrateReport, }, /// Session restore finished: clear stale chat projection, resync My Trades/Messages /// from SQLite, spawn background trade-DM and peer-chat relay rebuilds, then show