This guide explains the architecture and implementation of the Mostrix Text User Interface (TUI).
Mostrix is built using:
- Ratatui: For terminal rendering, layouts, and widgets.
- Crossterm: For terminal manipulation (raw mode, alternate screen) and input event handling.
The AppState struct in src/ui/app_state.rs manages the global state of the interface.
Source: src/ui/app_state.rs
pub struct AppState {
pub user_role: UserRole,
pub active_tab: Tab,
pub selected_order_idx: usize,
pub selected_dispute_idx: usize,
pub selected_in_progress_idx: usize,
pub active_chat_party: ChatParty,
pub admin_chat_input: String,
pub admin_chat_input_enabled: bool,
pub admin_dispute_chats: HashMap<String, Vec<DisputeChatMessage>>,
pub admin_chat_scrollview_state: tui_scrollview::ScrollViewState,
pub admin_chat_selected_message_idx: Option<usize>,
pub admin_chat_line_starts: Vec<usize>,
pub admin_chat_scroll_tracker: Option<(String, ChatParty, usize)>,
pub admin_chat_last_seen: HashMap<(String, ChatParty), AdminChatLastSeen>,
pub selected_settings_option: usize,
pub mode: UiMode,
pub messages: Arc<Mutex<Vec<OrderMessage>>>,
pub active_order_trade_indices: Arc<Mutex<HashMap<uuid::Uuid, i64>>>,
pub selected_message_idx: usize,
pub pending_notifications: Arc<Mutex<usize>>,
// …see source for observer and attachment fields…
}The screen is divided into three horizontal chunks using ratatui layouts. The available tabs and layout change dynamically based on the active UserRole.
Source: src/ui/mod.rs:523
let chunks = Layout::new(
Direction::Vertical,
[
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
],
)
.split(f.area());
- Header (3 lines): Renders the navigation tabs. The tab list is determined by the
UserRole(User vs Admin). - Body (remaining space): Renders the active tab content or forms.
- Footer / Status bar (3 lines): Renders the multi-line status bar with settings + connection details.
Mostrix supports two distinct roles, each with its own set of tabs and workflows.
Focused on trading and order management.
- Orders: View the global order book.
- My Trades: Manage active trades.
- Messages: Direct messages for trade coordination.
- Settings: Local configuration, including key rotation via Generate New Keys and mnemonic backup prompts. User mode only: Set Lightning Address (buyer) / Clear Lightning Address — optional
user@domain.comstored insettings.toml; confirm-save fetches LNURL metadata (payRequest) before persisting (seesrc/util/ln_address.rs,spawn_verify_and_save_ln_address_task). The visible menu and Enter routing shareADMIN_SETTINGS/USER_SETTINGSinsrc/ui/tabs/settings_tab.rs(SettingsMenuAction+ label per row;settings_action_for_index). - Create New Order: Form for publishing new orders.
Focused on dispute resolution and protocol management.
- Disputes Pending: List of disputes waiting to be taken. Only displays disputes with
Initiatedstatus (filtering implemented indisputes_tab.rs). Admins can select and take ownership of these disputes. - Disputes in Progress: Complete workspace for managing taken disputes (state:
InProgress), featuring:- Integrated chat system with buyer and seller
- Comprehensive dispute information header
- Dynamic message input with text wrapping
- Chat history with scrolling (PageUp/PageDown)
- Finalization popup for resolution actions
- Empty state: When no disputes are available, displays helpful key hints footer (filter +
↑↓: Select Dispute | Ctrl+H: Help); footer is width-aware (narrow terminals show only Ctrl+H).
- Observer: Read-only workspace for inspecting user-to-user encrypted chats via a shared key:
- Single shared-key input (64-char hex secret, paste-friendly)
- Fetches NIP-59 gift-wrap messages from relays for the last 7 days using the shared key's public key
- Decrypts messages and maps sender pubkeys to Buyer/Seller/Admin roles automatically
- Displays chat using the same formatting as the dispute chat (color-coded, right-aligned Buyer/Seller, left-aligned Admin)
- Supports file/image attachments with
Ctrl+Sto save (same popup as dispute chat) - Keyboard hints:
Enterto fetch chat,Ctrl+Cto clear all,Ctrl+Sto save attachment,Ctrl+Hfor help
- Settings: Role-specific configuration including:
- Add Dispute Solver
- Generate New Keys (rotates the Admin keypair)
- Manage relays and currency filters
For detailed information about admin dispute resolution workflows, see ADMIN_DISPUTES.md and FINALIZE_DISPUTES.md.
The interface uses a nested state machine defined by UiMode, UserMode, and AdminMode.
Source: src/ui/app_state.rs
pub enum UiMode {
// Shared modes (available to both user and admin)
Normal,
ViewingMessage(MessageViewState),
RatingOrder(RatingOrderState), // 1–5 stars → RateUser DM (Messages tab, action `rate`)
NewMessageNotification(MessageNotification, Action, InvoiceInputState),
OperationResult(OperationResult), // Generic operation result popup (success/info/error)
HelpPopup(Tab, Box<UiMode>), // Context-aware keyboard shortcuts (Ctrl+H); Box<UiMode> = mode to restore on close
SaveAttachmentPopup(usize), // Dispute chat: list index of selected attachment (Ctrl+S opens, ↑↓/Enter/Esc in popup)
ObserverSaveAttachmentPopup(usize), // Observer tab: list index of selected attachment (Ctrl+S opens, ↑↓/Enter/Esc in popup)
UserSaveAttachmentPopup(String, usize), // My Trades: pinned order_id + list index (Ctrl+S; order_id pinned so sidebar changes do not retarget save)
UserSendAttachmentPicker(String), // My Trades: pinned order_id + ratatui-explorer (Ctrl+O; Enter on file enqueues send job)
// User-specific modes
UserMode(UserMode),
// Admin-specific modes
AdminMode(AdminMode),
}Popups are implemented by rendering additional widgets on top of the main layout when the UiMode is not Normal. They are drawn at the end of the ui_draw function to ensure they appear as overlays.
The primary shared popup is the operation result modal, used for:
- Order creation / take-order flows
- Settings validation errors (invalid pubkey, relay, currency, Lightning address format, LNURL verification failure, etc.)
- Admin actions (add solver, finalize disputes)
- Blossom attachment downloads and Observer-mode shared-key errors
When the popup is closed (Esc or Enter) from the Disputes in Progress tab, the app stays on that tab and returns to ManagingDispute mode (it does not switch to the first tab).
Example: Rendering the OperationResult popup.
Source: src/ui/operation_result.rs (rendering), src/ui/orders.rs (OperationResult enum). Notable variants handled in handle_operation_result:
| Variant | Effect |
|---|---|
Success |
My Trades placeholder + order_chat_static |
PaymentRequestRequired |
Opens Pay / bond invoice popup (NewMessageNotification) |
OpenInvoicePopup |
Opens Add Invoice or waiting popup from synchronous execute (bond payout reply); does not show the operation-result modal |
InvoiceSubmitted |
Normalized to Info toast after optional buyer LN-address preference |
TradeClosed / OrderHistoryDeleted |
Side effects on Messages list, then Info toast |
OrderChatAttachmentSent |
Appends You chat row + JSON transcript save; clears pending_order_attachment_sends and sending_attachment_order_id when order_id matches; then normalized to Info (Attachment sent: …) |
OrderChatAttachmentError |
Early send failure (validate / encrypt / upload before DM); clears sending_attachment_order_id when order_id matches; normalized to Error popup. Generic OperationResult::Error from other tasks does not clear the in-flight send guard. |
OrderChatAttachmentSendFailed |
Stores PreparedOrderChatAttachment in AppState.pending_order_attachment_sends; clears sending_attachment_order_id when order_id matches; normalized to Error (Blossom URL + Ctrl+Shift+O retry hint) |
OperationResult::Info / Error text: render_operation_result splits on newlines, wraps at word boundaries (avoids mid-word breaks on long UUIDs), and sizes the popup from line count. Admin dispute finalization success uses a structured multi-line body from BondSlashChoice::finalize_success_message (see FINALIZE_DISPUTES.md).
// Operation result popup overlay (shared)
if let UiMode::OperationResult(result) = &app.mode {
operation_result::render_operation_result(f, result);
}Help popup (Ctrl+H):
- Open: Press Ctrl+H in normal or managing-dispute mode to show a context-aware shortcuts overlay for the current tab (Disputes in Progress, Observer, Settings, Orders, etc.).
- Content: The popup lists all relevant key bindings for that tab; e.g. in Disputes in Progress it shows filter toggle, Tab/Enter/Shift+I/Shift+F, scroll keys, and Ctrl+S to open the save-attachment list when applicable. On My Trades it includes PgUp/PgDn/End chat scroll, Ctrl+S (save attachment list), Ctrl+O (send file picker), and Ctrl+Shift+O (retry DM after upload ok / send failed).
- Close: Esc, Enter, or Ctrl+H close the popup; other keys are absorbed while it is open.
- Source:
src/ui/help_popup.rs(rendering),src/ui/key_handler/mod.rs(Ctrl+H and close handling).
Save attachment popup (Ctrl+S in Disputes in Progress, Observer tab, or My Trades):
- Open: When managing a dispute, viewing an observer chat, or on My Trades with a selected active order, press Ctrl+S to open a centered popup listing all file/image attachments. In Disputes in Progress, attachments are scoped to the current dispute and active party (Buyer or Seller). In Observer mode, attachments are drawn from all observer messages. On My Trades, attachments come from the selected order’s chat transcript (
get_order_attachment_messages). If there are no attachments, Ctrl+S does nothing. - In popup: ↑/↓ change selection, Enter enqueues the selected attachment on
save_attachment_txand closes the popup; Esc cancels. Other keys are absorbed. Footer shows "↑↓ Select, Enter Save, Esc Cancel". - Async download + popup:
spawn_save_attachmentruns on a Tokio task and sendsOperationResult::Info("Saved to …") orErroronorder_result_tx. The main loop applies those results inapply_order_resultand alsotry_recvs attachment and result channels before every frame (drain_save_attachment_queue,drain_send_order_attachment_queue,drain_order_result_queueinsrc/main.rs) so the operation-result modal appears without waiting for another key (My Trades may enqueue the job after a short async DB key lookup). - Saveable list: only attachments with a non-empty Blossom URL appear in the popup (
attachment_is_saveable/get_order_attachment_messagesinsrc/ui/helpers/chat_visibility.rs). - My Trades decrypt: when the sender did not embed a key in the attachment JSON, Mostrix derives the shared ChaCha20 key from
order_chat_shared_key_hexor ECDH (order_chat_decryption_key_bytesinsrc/util/chat_utils.rs) before writing the file. - Source:
src/ui/save_attachment_popup.rs(dispute, observer, and user order popups),src/ui/key_handler/mod.rs(open and popup key handling),src/main.rs(queue drain),src/ui/constants.rs(SAVE_ATTACHMENT_POPUP_HINT,FOOTER_CTRL_S_SAVE_FILE).
Send attachment picker (Ctrl+O on My Trades):
- Open: On My Trades with a selected active order, press Ctrl+O while
user_my_trades_interactive()is true. OpensUiMode::UserSendAttachmentPicker(order_id)with aratatui-explorermodal (build_send_attachment_explorerinsrc/ui/send_attachment_picker.rs). Starts indirs::document_dir()or$HOME. Does nothing whilesending_attachment_order_idis set (send already in flight). Build failures showOperationResult::Error("Could not open file picker: …"). - Filter: Only directories and files whose extension passes
attachment_extension_allowed(jpg/jpeg/png/pdf/mp4/mov/avi/doc/docxinsrc/util/file_validation.rs) appear in the list. - In picker: h/j/k/l (and other explorer keys routed via
FileExplorer::handle) navigate; Enter on a regular file (not..or a directory) enqueuesSendOrderAttachmentJob::FromPath { order_id, path }onsend_order_attachment_tx, setssending_attachment_order_id, and closes the picker; Esc cancels. Footer hint:SEND_ATTACHMENT_PICKER_HINT("Enter: Send file | Esc: Cancel | h/j/k/l: Navigate | …"). - Retry: Ctrl+Shift+O on the same selected order enqueues
SendOrderAttachmentJob::RetryPreparedwhenAppState.pending_order_attachment_sendsholds that order (upload succeeded but shared-key DM failed). Same in-flight guard as Ctrl+O. - Async pipeline + popup:
spawn_send_order_chat_attachmentinsrc/util/send_attachment.rsvalidates (including PNG/JPEG dimensions for images), encrypts, uploads to Blossom, builds mobile-compatible wire JSON, and sends the DM. Results arrive onorder_result_txasOrderChatAttachmentSent,OrderChatAttachmentError(early failure), orOrderChatAttachmentSendFailed(upload ok / DM fail);handle_operation_resultclearssending_attachment_order_idonly for those attachment-specific variants (scoped byorder_id). The main loop drainssend_order_attachment_rxandorder_result_rxbefore every draw (see STARTUP_AND_CONFIG.md). - Source:
src/ui/send_attachment_picker.rs,src/ui/key_handler/mod.rs(Ctrl+O / Ctrl+Shift+O and picker keys),src/util/send_attachment.rs,src/ui/constants.rs(FOOTER_CTRL_O_SEND_FILE,FOOTER_CTRL_SHIFT_O_RETRY,FOOTER_SENDING_ATTACHMENT,HELP_MY_TRADES_CTRL_O_SEND,HELP_MY_TRADES_CTRL_SHIFT_O_RETRY).
Backup New Keys popup (first launch + key rotation):
- Purpose: Displays the newly generated 12-word mnemonic so it can be backed up after Generate New Keys.
- Where it appears:
- On key rotation: shown as an overlay popup.
- On very first launch: shown as an overlay on the initial Orders/Disputes tab (Mostrix does not force switching to the Settings tab).
- Reminder: After saving the mnemonic, restart Mostrix so it uses the rotated keys everywhere.
Shared copy (help titles, footer hints, filter labels) lives in src/ui/constants.rs so strings are defined once and reused by the help popup and by width-aware footers (e.g. Disputes in Progress). Use these constants when adding or changing UI text to avoid duplication.
Input handling is centralized in src/ui/key_handler/ (mod.rs and submodules: enter_handlers, esc_handlers, navigation, etc.).
Users can switch between roles (User/Admin) and tabs using arrow keys.
- Left/Right: Switch tabs.
- Up/Down: Navigate within lists (Order book, Messages).
The handle_key_event function dispatches keys based on the current UiMode.
Example: Handling the Enter key (dispatched from key_handler/mod.rs to enter_handlers::handle_enter_key). Async feedback uses purpose-specific channels from EnterKeyContext: order_result_tx for orders, takes, disputes, bulk history cleanup, attachments, etc., and ln_address_result_tx for Lightning address verify-and-save only (LnAddressVerifyResult → main loop maps to OperationResult for the shared popup).
- Forms: Character input and Backspace are handled by
form_input::handle_char_inputandform_input::handle_backspacefor fields inFormStatewhileUiMode::UserMode(UserMode::CreatingOrder(_)).- Create New Order (
src/ui/order_form.rs,src/ui/orders.rs—FormState/FormField): Tab / Shift+Tab cycle focus; Space toggles buy/sell on Order Type and single/range on Fiat Amount; Enter submits; Esc cancels. - Global shortcut guard:
n/N(cancel) andc/C(copy invoice / observer clear) are handled before the genericChar(_)arm inkey_handler/mod.rs. When a text field is focused (is_creating_order_text_inputinform_input.rs— any field except Order Type), those keys are routed to form typing instead (fixes payment method labels like SEPA / Bizum). Outside the form,nstill drives confirmation cancel (handle_cancel_key);cstill copies PayInvoice / PayBondInvoice invoices.
- Create New Order (
- Invoices:
handle_invoice_inputhandles text entry for Lightning invoices, including support for bracketed paste mode. - Paste support: The event loop now centralizes paste routing for active inputs and supports:
Event::Paste(...)(bracketed paste)- mouse right-click paste (
MouseEventKind::Down(MouseButton::Right)) using clipboard read fallback This applies to invoice input, admin key/solver inputs, and observer shared-key input.
- Admin Chat:
handle_admin_chat_inputhandles direct text input in the "Disputes in Progress" tab:- Takes priority over other input handling (except invoice and key input)
- Supports direct character input and backspace
- Dynamic input box that grows from 1 to 10 lines
- Text wrapping with word boundary detection
- Input toggle: Press Shift+I to enable/disable chat input (prevents accidental typing)
- Visual feedback: Input title shows enabled/disabled state
- Copy to Clipboard: Pressing
Cin aPayInvoiceorPayBondInvoicenotification uses thearboardcrate to copy the invoice. On Linux, it uses theSetExtLinux::wait()method to properly wait until the clipboard is overwritten, ensuring reliable clipboard handling without arbitrary delays. - Exit Confirmation: Pressing
Qor selecting the Exit tab shows a confirmation popup before exiting the application. Use Left/Right to select Yes/No, Enter to confirm, or Esc to cancel. - Help popup: Press Ctrl+H (in normal or managing-dispute mode) to open a centered overlay with all keyboard shortcuts for the current tab. Press Esc, Enter, or Ctrl+H to close.
Renders a table of pending orders from the Mostro network. Status and order kinds are color-coded for readability.
Source: src/ui/orders_tab.rs
Displays a list of direct messages related to the user's trades. Messages are tracked as read or unread. The detail panel includes a trade timeline stepper (six columns): FlowStep from src/ui/orders.rs (message_trade_timeline_step), with per-column copy from src/ui/constants.rs (listing_timeline_labels). See buy order flow.md and sell order flow.md.
-
Sidebar labels:
message_action_compact_label_for_messageprefersorder_statusover rawaction(e.g. Pending order, Trade Completed) so reboot replay does not show stale action text. Non-actionable rows opened with Enter use that compact label in anOperationResult::Infopopup (enter_handlers.rs). -
Pending / bond-pending timeline:
Status::Pending,Status::WaitingTakerBond, andStatus::WaitingMakerBondmap toStepPendingOrder(discriminant 0). The stepper usesstep_number() == 0, so no column is highlighted (all steps gray) until the trade advances — avoids falsely lighting step 1 while the order is still on the book or awaiting bond. -
Post-success placeholder row: when
OperationResult::Successarrives before any DM row exists,try_placeholder_order_message_from_success(orders.rs) inserts one syntheticOrderMessagefor My Trades / Messages (action from status: maker →NewOrderorPayBondInvoicewhenWaitingMakerBond; taker →PayBondInvoice/WaitingSellerToPay/ etc.; never synthetictake-buy/take-sell).main.rsalso resyncs My Trades from DB afterSuccess, not only after history delete. -
Enter on a row: opens an invoice popup, a confirmation popup, the rating overlay (
RatingOrder) when the daemon sentaction: rate, or an info line for other actions (src/ui/key_handler/enter_handlers.rs). -
Rating overlay:
render_rating_orderinsrc/ui/tabs/tab_content.rs; keys Left/Right or +/- adjust stars, Enter submits, Esc closes. -
Invoice popups (
NewMessageNotification):AddInvoice/WaitingBuyerInvoicemap to invoice-submit popup mode. Ifsettings.tomlln_addressis non-empty,UiMode::ConfirmSavedLnAddressForInvoicemay appear first (YES / NO), listing the saved address; Left/Right moves the highlight; YES + Enter auto-submitsAddInvoiceviasubmit_add_invoice(message_handlers.rs) without opening the invoice input popup (UseSavedLnAddressis saved only after the send succeeds —OperationResult::InvoiceSubmittedinorder_ch_mng.rs). Choose NO and press Enter to open the manual invoice UI (ManualInvoice). Esc closes the confirm popup without committing YES or NO (handle_esc_keyinesc_handlers.rs).BuyerInvoicePreferenceperorder_id(src/ui/app_state.rs,src/ui/orders.rs) remembers the choice for that trade until Cancel Order from the popup removes it (message_handlers.rs) or the trade row is torn down (order_ch_mng.rs).PayInvoice/WaitingSellerToPaymap to payment popup mode.PayBondInvoice(Mostro Phase 1.5+ taker bond / Phase 5+ maker bond) maps to a dedicated bond popup mode (render_pay_bond_invoiceinsrc/ui/message_notification.rs). It mirrors the PayInvoice layout but uses a 🛡️ title (Anti-abuse Bond Invoice), a taker or maker amount label (viaMessageNotification.maker_bond_publish: "Bond invoice to pay" vs "Pay bond to publish your order"), and a yellow "Locked, not spent — refunded on normal completion" disclaimer. Primary button is Acknowledge (closes the popup; payment happens in the user's wallet); Cancel Order is still wired toAction::Cancel. The popup is gated onorder_status ∈ {WaitingTakerBond, WaitingMakerBond, None}and role (local_user_must_act_on_invoice_popup— status-based forPayBondInvoice, not listing kind). Sync paths:take_order(taker) andsend_new_order(maker) returnPaymentRequestRequired. Bonds are configurable in mostrod — when not enabled, create/take flows skip this popup.- All three popups provide two actions (
Primary+Cancel Order) via Left/Right selection; Enter confirms the selected action. PayInvoiceandPayBondInvoicekeep copy (C) and scroll (Up/Down,PageUp/PageDown) behavior while adding cancel selection.
ViewingMessage (trade confirmations) — render_message_view in src/ui/tabs/tab_content.rs:
- Used for actionable events that need a YES/NO before sending a follow-up to Mostro:
HoldInvoicePaymentAccepted,FiatSentOk,CooperativeCancelInitiatedByPeer, plus explicit user-triggered actions from My Trades (Cancel,FiatSent,Release). - Buttons are rendered with
helpers::render_yes_no_buttons(same visual pattern as exit/settings confirms: ✓ YES / ✗ NO, Left/Right to move selection, Enter to confirm, Esc to dismiss). - Handler:
handle_enter_viewing_messageinsrc/ui/key_handler/message_handlers.rs(only proceeds when YES is selected). Cooperative cancel completion surfacesOperationResult::TradeClosed, whichhandle_operation_resultresolves after removing the row from the Messages list (see MESSAGE_FLOW_AND_PROTOCOL.md).
Source: src/ui/tab_content.rs (render_messages_tab, render_message_view, render_rating_order)
A stateful form for creating new orders. It supports both fixed amounts and fiat ranges.
Fields (FormField in src/ui/orders.rs): Order Type (toggle), Currency, Amount (sats), Fiat Amount (+ optional max when range), Payment Method, Premium (%), Invoice (optional), Expiration (days).
Source: src/ui/order_form.rs, src/ui/key_handler/form_input.rs, src/ui/key_handler/navigation.rs (Tab focus)
Source: src/ui/app_state.rs — UiMode::default_for_role, UiMode::user_my_trades_interactive
- On
AppState::newandswitch_role, user mode starts inUiMode::UserMode(UserMode::Normal)(not bareUiMode::Normalalone). - Chat input, Ctrl+S, Ctrl+O, Ctrl+Shift+O, PgUp/PgDn/End scroll, and related shortcuts are active only when
app.mode.user_my_trades_interactive()is true:UiMode::NormalorUiMode::UserMode(UserMode::Normal). - Popups (save attachment, send attachment picker, operation result, viewing message, etc.) set other
UiModevariants; My Trades shortcuts resume when the popup closes back to normal user mode.
The My Trades workspace (src/ui/tabs/order_in_progress_tab.rs) now shows richer order context and cleaner chat readability:
- Header metadata (two layers):
- Stable (one-time): order id, kind, created-at, trade index, and initiator (role: Maker/Taker + truncated trade pubkey) come from
OrderChatStaticHeaderinAppState.order_chat_static. The map is filled when the user creates an order (maker) or takes an order (taker, including thePaymentRequestRequiredpath for bothPayInvoiceandPayBondInvoiceresponses — the variant carries the originatingAction), and fromsync_user_order_history_messages_from_dbinsrc/ui/helpers/startup.rs(parses localordersrows so restarts do not lose the header). Entries are removed when a trade is closed or history cleanup deletes the row (handle_operation_resultinsrc/util/dm_utils/order_ch_mng.rs). - Live (from DMs): status, amount / fiats, payment method, premium, and buyer/seller rating (when present) still come from the message projection (see below).
- Stable (one-time): order id, kind, created-at, trade index, and initiator (role: Maker/Taker + truncated trade pubkey) come from
- Privacy / ratings: there is no placeholder row for
Privacy:/Buyer -/Seller -until trade privacy can be sourced from the same context as disputes (DMSmallOrderdoes not carry those flags). Buyer Rating: / Seller Rating: lines are shown only when reputation exists:helpers::build_active_order_chat_listmergesPayload::PeerwithUserInfowhenpeer.pubkeymatchesbuyer_trade_pubkey/seller_trade_pubkeyfromPayload::Order, and the header useshelpers::format_user_ratingfor display. - Chat rendering: user/peer messages are wrapped to fit pane width (including splitting overlong tokens by Unicode character count so lines do not overflow); peer messages are right-aligned for better sender separation.
- Chat scrolling: message history uses
tui_scrollview::ScrollViewwith full content height (not viewport height) and an always-visible vertical scrollbar — same pattern as Disputes in Progress and Observer. PgUp/PgDn scroll the chat; End jumps to the latest messages. Auto-scroll-to-bottom runs when new messages arrive, when switching orders, or after sending (order_chat_scroll_tracker,scroll_order_chat_messages/scroll_order_chat_after_sendinsrc/ui/key_handler/chat_helpers.rs). - Attachments (receive + transcript): encrypted file/image messages show as yellow lines with 🖼/📎 icons; the block title adds a file count; a yellow toast appears on new peer relay merges. Transcripts under
~/.mostrix/orders_chat/persist attachment metadata as JSON so Ctrl+S works after restart (legacy placeholder lines hydrate from relay). Ctrl+S opens the save popup (see above). - Attachments (send): Ctrl+O opens
UserSendAttachmentPicker(ratatui-explorerinsrc/ui/send_attachment_picker.rs); Enter on a file enqueuesSendOrderAttachmentJob::FromPathonsend_order_attachment_tx. Backend insrc/util/send_attachment.rs: validate → encrypt (shared key) → Blossom upload (NIP-24242 auth signed with order trade key) → shared-key DM. Ctrl+Shift+O enqueuesRetryPreparedwhenAppState.pending_order_attachment_sendshas the selected order (upload ok / DM failed).sending_attachment_order_idblocks duplicate sends while in flight; cleared only by attachment-specificOperationResultvariants inorder_ch_mng.rs, not by unrelated errors onorder_result_tx. - Empty states: sidebar/main panel copy is clearer ("No active orders yet"), and the help hint remains visible in the footer.
- Footer shortcuts (width-aware):
- Shift+I toggles chat input.
- Shift+C cooperative cancel (YES/NO popup).
- Shift+F mark fiat sent (YES/NO popup).
- Shift+R release sats (YES/NO popup).
- Shift+V rate counterparty (opens 1–5 star rating picker).
- PgUp/PgDn scroll chat history; End jump to bottom.
- Ctrl+S save attachment (when the selected order has attachments).
- Ctrl+O send attachment (file picker); Ctrl+Shift+O retry DM when a prepared send is pending.
- Sending attachment… (
FOOTER_SENDING_ATTACHMENT) whilesending_attachment_order_idmatches the selected order. - Shift+H opens the shortcuts popup for the current tab.
- Projection vs static: the DM-based list row (
OrderChatListIteminsrc/ui/helpers/order_chat_projection.rs) holds live fields only:status, first-seen economic snapshot fromPayload::Order(amount, fiat, payment, premium),trade_index(from any message in the order), and buyer/seller trade pubkeys plus reputation fromPayload::Peer. It no longer carries kind,created_at, or initiator metadata (those are onorder_chat_staticabove). - Selection correctness (shared projection): both the sidebar list and Enter/send handlers derive the selected order from the same projection (
helpers::build_active_order_chat_list), with identical filtering and ordering. This prevents UI/action desync whereselected_order_chat_idxcould resolve a different trade than the highlighted row. Trade ID in the header still falls back to projectiontrade_indexif a static entry is not yet in the map (e.g. race before the result handler runs).
Source: src/ui/tabs/order_in_progress_tab.rs
Mostrix uses a consistent color palette defined in src/ui/mod.rs:
PRIMARY_COLOR:#b1cc33(Mostro green).BACKGROUND_COLOR:#1D212C.- Status Colors: Yellow for pending, Green for active/success, Red for disputes/cancellation.
- Chat Colors:
- Cyan for Admin messages
- Green for Buyer messages
- Red for Seller messages
Source: src/ui/mod.rs (color constants), src/ui/orders_tab.rs and src/ui/disputes_in_progress_tab.rs (status colors), src/ui/disputes_in_progress_tab.rs (chat colors)
Status: ✅ Fully Implemented (NIP‑59 + Shared Keys)
The admin chat system in the "Disputes in Progress" tab provides real-time, Nostr-based communication using NIP‑59 gift-wrap events and per‑dispute shared keys derived between the admin key and each party’s trade pubkey.
The previous monolithic helper file was split into focused modules under src/ui/helpers/:
mod.rs: compatibility re-export layer (crate::ui::helpers::...) for existing call sites.layout.rs: popup/help/button rendering helpers.formatting.rs: UI formatting helpers (ratings, order id labels, finalized status check).chat_visibility.rs: per-party visibility and selection helpers.chat_render.rs: wrapped line formatting plus list/scrollview builders.chat_storage.rs: transcript parse/load/save logic for disputes and user order chat.attachments.rs: attachment JSON parse/serialize, outboundbuild_*_encrypted_jsonhelpers, placeholders, and toast expiration/building.order_chat_projection.rs: shared "My Trades" active-order projection (single source of truth for sidebar ordering and Enter/action resolution). MergesPayload::Order(economic first snapshot, buyer/seller trade pubkeys) andPayload::Peer(reputation when pubkeys match). Payload merge runs even whenorder_kindis missing on a message, so Peer reputation is not skipped for odd DM shapes.startup.rs: startup hydration/recovery, applying chat updates to app state, and seedingorder_chat_staticfromorderswhen syncing user history.
pub enum ChatSender {
Admin,
Buyer,
Seller,
}
pub struct DisputeChatMessage {
pub sender: ChatSender,
pub content: String,
pub timestamp: i64,
pub target_party: Option<ChatParty>, // For Admin messages: which party this was sent to
}
pub struct AdminChatLastSeen {
pub last_seen_timestamp: Option<u64>, // Last seen message timestamp for incremental fetches
}Storage:
AppState.admin_dispute_chats: HashMap<String, Vec<DisputeChatMessage>>keyed by dispute ID.AppState.admin_chat_last_seen: HashMap<(String, ChatParty), AdminChatLastSeen>keyed by (dispute_id, party).
- Direct input: Type immediately without mode switching (when input enabled).
- Input toggle: Press Shift+I to enable/disable chat input.
- Dynamic sizing: Input box grows from 1 to 10 lines based on content.
- Text wrapping: Intelligent word-boundary wrapping with trim behavior.
- Scrolling:
- PageUp/PageDown: Navigate through message history.
- End: Jump to bottom of chat (latest messages).
- Visual scrollbar: Right-side scrollbar shows position (↑/↓/│/█ symbols).
- Party filtering:
- Admin messages are only shown in the chat view of the party they were sent to (based on
target_party). - Buyer/Seller messages are only shown in their respective chat views.
- Admin messages are only shown in the chat view of the party they were sent to (based on
- Visual feedback: Focus indicators, color-coded messages, alignment prefixes, input state indicators.
The key handler processes input in this order:
- Invoice input (highest priority, when in invoice mode).
- Key input (for settings popups).
- Shift+I toggle (for enabling/disabling admin chat input).
- Admin chat input (takes priority in Disputes in Progress tab, only when enabled).
- Other character/form input.
Source: src/ui/key_handler/mod.rs (handle_admin_chat_input, Shift+I toggle).
-
Shared key derivation:
- When a dispute is taken (
AdminDispute::new), per-party shared keys are eagerly derived using ECDH:nostr_sdk::util::generate_shared_key(admin_secret, counterparty_pubkey). - Two shared keys are stored (as hex) in the
admin_disputestable:buyer_shared_key_hexandseller_shared_key_hex. - The same derivation is used by
mostro-chatso both the admin and the counterparty can independently derive the same shared key and subscribe to the same events.
- When a dispute is taken (
-
Message addressing:
- Admin chat messages are addressed to the shared key's public key (not the counterparty's trade pubkey directly).
- The admin reads
admin_privkeyfromsettings.tomlto sign the inner rumor; the gift wrapptag targets the shared key pubkey.
-
Sending messages:
- Admin messages are sent via
send_admin_chat_message_via_shared_key(spawned as an async task to avoid blocking the UI):- Rumor content: Mostro protocol format
(Message::Dm(SendDm, TextMessage(...)), None). - The gift wrap is built using
EventBuilder::gift_wrapwith the admin keys and the shared key public key as the recipient. - Published to relays without blocking the main UI thread.
- Rumor content: Mostro protocol format
- Admin messages are sent via
-
Receiving messages:
- The main loop calls
spawn_admin_chat_fetchevery 2 seconds on the sharedadmin_chat_intervaltimer when in Admin mode (User mode uses the same timer forspawn_user_order_chat_fetch). Each spawn runsfetch_admin_chat_updatesin a one-off task. A single-flight guard (CHAT_MESSAGES_SEMAPHORE) ensures only one shared-key chat fetch runs at a time; overlapping interval ticks skip spawning until the current fetch completes. - For each in-progress dispute, the fetch:
- Rebuilds buyer/seller shared
Keysfrom the stored hex. - Fetches
GiftWrapevents addressed to each shared key's public key (7-day rolling window). - Decrypts each event using the shared key (standard NIP-59 or simplified mostro-chat format).
- Uses
last_seen_timestampto skip already-processed events. - Skips events signed by the admin identity to avoid duplicating locally-sent messages.
- Rebuilds buyer/seller shared
- The main loop calls
-
Behavior on restart (Chat Restore at Startup):
- Admin chat uses a hybrid persistence model to provide instant UI restore and incremental sync:
-
For each in‑progress dispute, chat transcripts are stored as human‑readable files under:
~/.mostrix/disputes_chat/<dispute_id>.txt -
On startup,
recover_admin_chat_from_files:- Reads each existing transcript file.
- Rebuilds
AppState.admin_dispute_chatsso the Disputes in Progress tab immediately shows previous messages. - Computes the latest timestamps per party and updates
AppState.admin_chat_last_seen.
-
The latest buyer/seller timestamps are also persisted in the
admin_disputestable (buyer_chat_last_seen,seller_chat_last_seen) viaupdate_chat_last_seen_by_dispute_idso that:- Background NIP‑59 fetches only request newer events (7-day rolling window).
- Chat resumes from where it left off without replaying the full history.
-
- Admin chat uses a hybrid persistence model to provide instant UI restore and incremental sync:
Mostrix includes a safety feature to prevent accidental exits:
- Trigger: Navigate to the Exit tab (User or Admin) and confirm
- Popup: Shows confirmation dialog with "Are you sure you want to exit Mostrix?"
- Navigation: Use Left/Right arrows to select Yes/No buttons
- Confirmation: Press Enter to confirm exit, or Esc to cancel
- Visual: Green "✓ YES" button and red "✗ NO" button with clear styling
Source: src/ui/exit_confirm.rs, src/ui/key_handler/enter_handlers.rs