Skip to content

Feat: File-attachment and custom wrapped messages#29

Merged
grunch merged 5 commits into
mainfrom
file-attachment
Feb 19, 2026
Merged

Feat: File-attachment and custom wrapped messages#29
grunch merged 5 commits into
mainfrom
file-attachment

Conversation

@arkanoider

@arkanoider arkanoider commented Feb 18, 2026

Copy link
Copy Markdown
Collaborator

SUMMARY

  • Admin chat: custom NIP-44 gift wrap (ephemeral key, PoW from settings) for
    send; unwrap uses NIP-44 only to match mostro protocol chat
  • Attachments: derive shared key in blossom (ECDH admin_sk + sender_pubkey);
    Ctrl+S auto-derives decryption key from dispute buyer/seller pubkey when missing
  • Helpers: support hex (24 chars) or base64 nonce for attachment JSON
  • UI: Seller chat messages use Magenta instead of Red for readability
  • Models: log error when buyer/seller pubkeys differ but derived shared keys
    are identical (guards against bad relay data)
  • chat_utils: add unit test for different users -> different shared keys;
    move test module to end of file (clippy items_after_test_module)
  • Deps: reqwest http2 feature
  • Docs: ADMIN_DISPUTES (Magenta, shared-key validation); KEY_MANAGEMENT (validation)

Summary by CodeRabbit

  • New Features

    • Attachments in admin dispute chats with encrypted download and Ctrl+S save shortcut; files saved to the user downloads location.
    • Restart-safe per-dispute chat transcripts for instant UI restore.
  • Enhancements

    • Chat UI shows attachment counts in titles and dynamic footer hints (Ctrl+S when applicable).
    • Toast notifications for attachment events and improved chat persistence/last-seen behavior.
  • Style

    • Admin chat seller color updated to magenta.

arkanoider and others added 4 commits February 8, 2026 20:20
- Add ChatAttachment model and attachment field on DisputeChatMessage
- Parse image_encrypted/file_encrypted JSON in apply_admin_chat_updates;
  set attachment_toast for new attachments
- Blossom module: blossom_url_to_https, fetch_blob, ChaCha20-Poly1305
  decrypt, save_attachment_to_disk, spawn_save_attachment
- Ctrl+S in dispute chat saves selected attachment to
  ~/.mostrix/downloads/ (optional decrypt when key provided)
- UI: chat block title file count, transient toast, footer hint
  "Ctrl+S: Save file", yellow styling for attachment lines
- Transcript placeholder for attachments; clear toast on key press
- Docs: ADMIN_DISPUTES and MESSAGE_FLOW_AND_PROTOCOL updated
- Deps: chacha20poly1305 (reqwest already present)
Derive per-dispute shared keys via generate_shared_key(admin_secret, counterparty_pubkey) and persist them in admin_disputes. Admin chat messages are now addressed to the shared key pubkey instead of trade pubkeys, mirroring the mostro-chat model.
- Admin chat: custom NIP-44 gift wrap (ephemeral key, PoW from settings) for
  send; unwrap uses NIP-44 only to match mostro protocol chat
- Attachments: derive shared key in blossom (ECDH admin_sk + sender_pubkey);
  Ctrl+S auto-derives decryption key from dispute buyer/seller pubkey when missing
- Helpers: support hex (24 chars) or base64 nonce for attachment JSON
- UI: Seller chat messages use Magenta instead of Red for readability
- Models: log error when buyer/seller pubkeys differ but derived shared keys
  are identical (guards against bad relay data)
- chat_utils: add unit test for different users -> different shared keys;
  move test module to end of file (clippy items_after_test_module)
- Deps: reqwest http2 feature
- Docs: ADMIN_DISPUTES (Magenta, shared-key validation); KEY_MANAGEMENT (validation)

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR moves admin chat to per-dispute ECDH-derived shared keys stored in the DB, adds ChaCha20-Poly1305 Blossom attachment download/decrypt/save flow, updates migrations/models/UI/input to handle shared-key chat and attachments, and wires a Ctrl+S save flow with a background save task.

Changes

Cohort / File(s) Summary
Database & Models
src/db.rs, src/models.rs, docs/DATABASE.md
Adds buyer_shared_key_hex / seller_shared_key_hex to admin_disputes, migration logic to ALTER TABLE if missing, and AdminDispute fields/insert/update bindings to persist per-dispute shared keys.
Core Chat & State
src/ui/chat.rs, src/ui/state.rs, src/ui/mod.rs, src/ui/app_state.rs
Adds ChatAttachmentType, ChatAttachment, optional attachment on DisputeChatMessage, re-exports, and transient attachment_toast UI state.
Chat Utilities & Crypto
src/util/chat_utils.rs, src/util/mod.rs, src/util/blossom.rs
Implements shared-key ECDH helpers (derive/keys_from_hex), replaces pubkey-based wrap/unwrap with shared-key flows, and adds blossom module for blossom:// → HTTPS, blob fetch, ChaCha20-Poly1305 decryption, and save/spawn_save_attachment.
UI Input & Key Handling
src/ui/key_handler/mod.rs, src/ui/key_handler/input_helpers.rs, src/ui/key_handler/enter_handlers.rs
Replaces send-to-pubkey path with send_via_shared_key, updates function signatures, adds Ctrl+S handling to send (dispute_id, ChatAttachment) on a save_attachment channel.
UI Helpers & Tabs
src/ui/helpers.rs, src/ui/tabs/disputes_in_progress_tab.rs
Adds parsing/rendering for attachment placeholders, deduplication including attachments, attachment counting/selection helpers, toast expiry, dynamic footer/title with attachment count and Ctrl+S hint.
Main Loop & Integration
src/main.rs, src/util/order_utils/execute_take_dispute.rs, src/util/order_utils/fetch_scheduler.rs
Introduces save_attachment_tx/rx wiring and spawn_save_attachment usage; passes admin_keys to AdminDispute::new where appropriate; removes admin_keys param from fetch scheduler and adapts fetch call to per-dispute shared-key fetch.
Dependencies & Repo
Cargo.toml, .gitignore
Adds chacha20poly1305 crate and reqwest http2 feature; ignores .cursor/ directory addition to .gitignore.
Documentation
README.md, docs/ADMIN_DISPUTES.md, docs/KEY_MANAGEMENT.md, docs/MESSAGE_FLOW_AND_PROTOCOL.md, docs/STARTUP_AND_CONFIG.md, docs/TUI_INTERFACE.md
Extensive docs updates describing per-dispute shared-key model, ECDH derivation/storage, GiftWrap addressing to shared-key pubkeys, restart-safe transcripts, attachment format/flow, and UI shortcut/UX changes.

Sequence Diagram(s)

sequenceDiagram
    participant Admin
    participant App as App State
    participant DB as Database
    participant Utils as Chat Utils
    participant Relay as Relay Network
    participant Blossom as Blossom Server

    Admin->>App: create/load AdminDispute
    App->>Utils: derive_shared_keys(admin_keys, party_pubkey)
    Utils->>DB: store buyer/seller_shared_key_hex

    Admin->>App: input chat message
    App->>Utils: send_admin_chat_message_via_shared_key(shared_key_hex)
    Utils->>Relay: publish GiftWrap addressed to shared_key_pubkey

    App->>Utils: fetch_admin_chat_updates(disputes, last_seen)
    Utils->>DB: rebuild Keys from shared_key_hex
    Utils->>Relay: query GiftWraps for shared_key_pubkey
    Relay->>Utils: events
    Utils->>App: AdminChatUpdate (messages, attachments)

    Admin->>App: Ctrl+S on selected attachment
    App->>Blossom: fetch_blob(blossom_url)
    Blossom->>App: encrypted blob
    App->>Utils: decrypt_blob(shared_key, blob)
    Utils->>App: decrypted file
    App->>DB: save_attachment_to_disk (~/.mostrix/downloads)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Catrya
  • grunch

"🐰
I dug a hole of keys and bloom,
Shared ECDH lights each chat room,
Blossoms guard files in ciphered night,
Ctrl+S saves them safe and right,
Hooray — transcripts glow by moonlight!"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Feat: File-attachment and custom wrapped messages' directly and accurately describes the two main features introduced in the changeset: file attachment support and custom wrapped messages for admin chat.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch file-attachment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkanoider arkanoider changed the title File-attachment Feat: File-attachment and custom wrapped messages Feb 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/KEY_MANAGEMENT.md (1)

79-99: ⚠️ Potential issue | 🟡 Minor

Duplicate ## Database Persistence section header.

Lines 79 and 99 both use ## Database Persistence. The second instance (line 99) covers an entirely separate topic (the critical mnemonic/index persistence warning). The duplicate heading breaks Markdown anchor navigation and makes both sections hard to link to.

📝 Suggested fix
-## Database Persistence
-
-Maintaining the state of trade indices is **critical**. If the `trade_index` associated with an order is lost…
+## Critical: Trade Index and Mnemonic Persistence
+
+Maintaining the state of trade indices is **critical**. If the `trade_index` associated with an order is lost…
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/KEY_MANAGEMENT.md` around lines 79 - 99, The second identical section
header "## Database Persistence" duplicates the earlier one and should be
renamed to a distinct, descriptive title (e.g., "## Mnemonic and Index
Persistence Warning" or similar) so anchors and links don't collide; update any
internal links or references that point to that header accordingly and verify
the surrounding content mentioning the critical mnemonic/index persistence
warning still reads correctly.
docs/TUI_INTERFACE.md (1)

217-222: ⚠️ Potential issue | 🟡 Minor

DisputeChatMessage doc snippet is missing the new attachment field.

This PR adds attachment: Option<ChatAttachment> to DisputeChatMessage, but the struct shown in this documentation block still reflects the old shape. Consider adding it so the docs stay in sync.

Suggested update
 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 attachment: Option<ChatAttachment>, // Parsed file/image attachment metadata
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/TUI_INTERFACE.md` around lines 217 - 222, The docs snippet for
DisputeChatMessage is out of sync: update the struct shown in TUI_INTERFACE.md
to include the new field attachment: Option<ChatAttachment>. Locate the
DisputeChatMessage example in the docs and add the attachment field with the
correct type and optionality (matching the code's DisputeChatMessage and
ChatAttachment symbols) so the documentation reflects the current struct shape.
src/models.rs (1)

520-527: ⚠️ Potential issue | 🟠 Major

Add WHERE status = ? clause to filter by InProgress disputes.

The SQL query contains no ? placeholder, so the .bind(DisputeStatus::InProgress.to_string()) call on line 525 has no effect. The function returns all disputes instead of only InProgress ones. Evidence suggests filtering was intended: the result is assigned to app.admin_disputes_in_progress (lines 192 and 270 in src/main.rs), and DisputeStatus::InProgress is explicitly bound. Add WHERE status = ? to the query to fix this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models.rs` around lines 520 - 527, In get_all (pub async fn get_all(pool:
&SqlitePool) -> Result<Vec<AdminDispute>>), the SQL query passed to
sqlx::query_as::<_, AdminDispute> is missing a WHERE clause so the subsequent
.bind(DisputeStatus::InProgress.to_string()) is ignored; update the query string
to include "WHERE status = ?" before the "ORDER BY taken_at DESC" so the bind
filters rows to DisputeStatus::InProgress and then keep the existing .bind(...)
and .fetch_all(pool).await? handling.
🧹 Nitpick comments (5)
Cargo.toml (1)

32-32: Consider bumping reqwest minimum version spec to "0.13.2".

The latest published reqwest version is 0.13.2. The current "0.13.1" spec resolves to ^0.13.1 so Cargo.lock will already pick up 0.13.2, but aligning the spec with the actual minimum tested version avoids regressing to a known-older patch in edge-case resolution scenarios.

The native-tls + http2 feature combination is valid: reqwest 0.13 made native-tls imply ALPN automatically, so most users will now get HTTP/2 upgrades. The http2 feature is a default feature in reqwest 0.13, so it must be listed explicitly when default-features = false, which this declaration correctly does.

✏️ Optional: bump minimum version
-reqwest = { version = "0.13.1", default-features = false, features = ["native-tls", "json", "http2"] }
+reqwest = { version = "0.13.2", default-features = false, features = ["native-tls", "json", "http2"] }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Cargo.toml` at line 32, Update the reqwest dependency spec in Cargo.toml from
"0.13.1" to "0.13.2" by changing the line defining reqwest = { version =
"0.13.1", default-features = false, features = ["native-tls", "json", "http2"] }
so the minimum required reqwest version matches the latest published 0.13.2.
src/util/chat_utils.rs (1)

229-231: _admin_keys is a dead parameter in the public API surface.

The parameter is never read in the function body. Since this is a pub async fn, the dead parameter pollutes the call-site signature and every caller must pass a reference they know is ignored. Consider removing it or replacing it with _: &Keys if it must be kept for interface stability.

♻️ Proposed removal
 pub async fn fetch_admin_chat_updates(
     client: &Client,
-    _admin_keys: &Keys,
     disputes: &[AdminDispute],
     admin_chat_last_seen: &HashMap<(String, ChatParty), AdminChatLastSeen>,
 ) -> Result<Vec<AdminChatUpdate>, anyhow::Error> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util/chat_utils.rs` around lines 229 - 231, The public async function
fetch_admin_chat_updates currently accepts an unused parameter _admin_keys;
either remove this parameter from the function signature (and update all callers
to stop passing it) or explicitly mark it as ignored by renaming to _: &Keys to
preserve the API while signaling it's unused; update the function declaration
for fetch_admin_chat_updates and any call sites accordingly, and run a build to
ensure no remaining references or warnings.
src/ui/key_handler/mod.rs (1)

168-211: Ctrl+S save-attachment handler looks correct; consider logging when derivation is skipped.

The nested guard structure is clear. The ECDH fallback derivation for missing decryption keys (lines 182–203) correctly maps ChatSender::Buyerdispute.buyer_pubkey and ChatSender::Sellerdispute.seller_pubkey, skipping Admin senders.

One minor observation: if the pubkey string is present but fails to parse (line 195), or if derive_shared_key fails (line 196), the attachment is still sent to the channel without a decryption key, resulting in an .enc file being saved without decryption. This is the intended fallback per the PR description, but there's no user-visible indication that key derivation failed — only the blossom.rs save path will name the file .enc. Consider adding a log::warn! in the error branches so the admin can diagnose why a file wasn't decrypted.

🔧 Optional: add logging for derivation failures
                                     if let Ok(sender_pk) = PublicKey::parse(pk_str) {
                                         if let Ok(shared) = crate::util::blossom::derive_shared_key(
                                             admin_keys, &sender_pk,
                                         ) {
                                             attachment.decryption_key = Some(shared.to_vec());
+                                        } else {
+                                            log::warn!("Failed to derive shared key for attachment in dispute {}", dispute_id_key);
                                         }
+                                    } else {
+                                        log::warn!("Failed to parse sender pubkey for attachment decryption: {}", pk_str);
                                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/key_handler/mod.rs` around lines 168 - 211, The handler sends
attachments even when pubkey parsing (PublicKey::parse for pk_str) or ECDH key
derivation (crate::util::blossom::derive_shared_key using admin_keys) fails; add
explicit logging in those failure branches so admins see why decryption_key
remained None. Specifically, inside the block handling
attachment.decryption_key.is_none() (the match on msg.sender and the nested
if-let Ok(sender_pk) / if-let Ok(shared) branches), add log::warn! messages when
PublicKey::parse(pk_str) returns Err and when derive_shared_key returns Err
(include dispute.dispute_id and msg.sender/context), then proceed to send via
tx.send((dispute_id_key, attachment)) as before.
src/ui/tabs/disputes_in_progress_tab.rs (1)

461-468: Side effect in render function — acceptable but worth noting.

Clearing attachment_toast inside render_disputes_in_progress is a side effect in what is conceptually a render pass. This works fine here since app is &mut, but if you ever refactor toward a stricter model/view separation, this expiry logic would belong in the main loop or a dedicated tick handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/tabs/disputes_in_progress_tab.rs` around lines 461 - 468, The
render_disputes_in_progress function currently mutates app by clearing
app.attachment_toast when the toast has expired, which is a side effect that
belongs in your main update/tick path; move the expiry logic out of
render_disputes_in_progress and into a centralized timer/tick handler (e.g., the
main event loop or a new handle_tick/update_timers function) that runs before
rendering, and have render_disputes_in_progress only read app.attachment_toast;
locate the code that checks app.attachment_toast and ATTACHMENT_TOAST_DURATION
and extract it into that tick/update function (or call a small helper like
expire_attachment_toast(&mut app)) so behavior is preserved without mutating
state during render.
src/ui/helpers.rs (1)

271-316: Consider logging when attachment JSON is partially valid but unparseable.

If the JSON has a recognized "type" (image_encrypted / file_encrypted) but is missing a required field (e.g., blossom_url, nonce, filename), the function silently returns None and the raw JSON blob is shown to the user as a plain chat message. A log::warn before returning None after matching the type would help diagnose relay-side or sender-side issues.

Sketch
     let (file_type, icon) = match msg_type {
         "image_encrypted" => (ChatAttachmentType::Image, "🖼"),
         "file_encrypted" => (ChatAttachmentType::File, "📎"),
         _ => return None,
     };
-    let blossom_url = obj.get("blossom_url")?.as_str()?.to_string();
-    let nonce_str = obj.get("nonce")?.as_str()?;
-    let nonce = decode_nonce_12(nonce_str)?;
-    let filename = obj.get("filename")?.as_str()?.to_string();
+    let blossom_url = match obj.get("blossom_url").and_then(|v| v.as_str()) {
+        Some(u) => u.to_string(),
+        None => { log::warn!("Attachment JSON missing blossom_url"); return None; }
+    };
+    let nonce_str = match obj.get("nonce").and_then(|v| v.as_str()) {
+        Some(n) => n,
+        None => { log::warn!("Attachment JSON missing nonce"); return None; }
+    };
+    let nonce = match decode_nonce_12(nonce_str) {
+        Some(n) => n,
+        None => { log::warn!("Attachment JSON has invalid nonce: {}", nonce_str); return None; }
+    };
+    let filename = match obj.get("filename").and_then(|v| v.as_str()) {
+        Some(f) => f.to_string(),
+        None => { log::warn!("Attachment JSON missing filename"); return None; }
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/helpers.rs` around lines 271 - 316, In try_parse_attachment_message,
when msg_type matches "image_encrypted" or "file_encrypted" but subsequent
required fields (blossom_url, nonce, filename, or key decoding) are missing
causing the function to return None, add a log::warn that includes the msg_type
and the original content (or which key was missing) so partial/invalid
attachment JSON is visible in logs; place this warning immediately before
returning None in those failure paths after extracting msg_type (reference
symbols: try_parse_attachment_message, msg_type, blossom_url, nonce, filename,
decryption_key).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/ADMIN_DISPUTES.md`:
- Around line 691-692: Update the stale color reference in the message-header
description: change the "Red for Seller" phrase in the "Sender - date - time"
line to "Magenta for Seller" so it matches the earlier "Seller (Magenta)"
wording (i.e., keep "Cyan for Admin, Green for Buyer, Magenta for Seller"
consistent).

In `@README.md`:
- Line 123: Update the keyboard shortcuts list entry that currently reads "Use
Tab to switch chat view, Shift+I to enable/disable chat input, PageUp/ PageDown
to scroll, End to jump to latest. Press Shift+F to open the finalization popup."
to include the new Ctrl+S shortcut; explicitly document that pressing Ctrl+S
saves the selected attachment to ~/.mostrix/downloads/ (e.g., add ", Ctrl+S to
save selected attachment to ~/.mostrix/downloads/" to that sentence) so readers
of the README can discover the feature.

In `@src/ui/chat.rs`:
- Line 47: ChatAttachment currently has a dead field nonce: Vec<u8> that is
never used in decryption (decrypt_blob reads nonce from the downloaded blob) and
is discarded by spawn_save_attachment; either remove the nonce field from the
ChatAttachment struct or explicitly document it as metadata-only (add a doc
comment above ChatAttachment::nonce) so intent is clear; update any construction
sites that set nonce (e.g., places that build ChatAttachment) to stop providing
it if removed, and run cargo check to ensure no remaining references to
ChatAttachment::nonce remain.

In `@src/util/blossom.rs`:
- Around line 64-83: The code currently calls res.bytes().await which buffers
the entire response before checking max_bytes; change to first inspect
res.headers().get("content-length") and error if it exceeds max_bytes, and then
stream the body via res.bytes_stream() (or equivalent) and consume chunks
incrementally, summing their lengths and returning an error if the running total
exceeds max_bytes; update the error mapping (where map_err is used) to return
the same anyhow! style on streaming/early-termination failure and keep the
existing variables client, url, timeout, and max_bytes to locate the callsite.

In `@src/util/chat_utils.rs`:
- Around line 212-215: The loop in chat_utils.rs currently drops messages when
ts <= last_seen which can permanently skip messages that share the same
timestamp; change the boundary check to use ts < last_seen so messages with ts
== last_seen are retained (they may be deduplicated later by the UI), and as a
follow-up consider deduplicating by event id (use the message/event id field)
instead of relying on timestamps for correctness.
- Around line 153-161: The hardcoded limit(100) on the Filter (created via
Filter::new() with Kind::GiftWrap, pubkey shared_keys.public_key(), since
Timestamp::from(wide_since)) can silently drop messages; replace this single
fixed batch with either a larger configurable batch size constant or,
preferably, implement pagination: request events in repeated batches (using the
same Filter parameters but advancing the time window or cursor between requests)
and accumulate results until a batch is smaller than the requested size,
ensuring no events within the wide_since window are missed; make the batch size
configurable and document/update places that rely on the previous 100-item
behavior.

---

Outside diff comments:
In `@docs/KEY_MANAGEMENT.md`:
- Around line 79-99: The second identical section header "## Database
Persistence" duplicates the earlier one and should be renamed to a distinct,
descriptive title (e.g., "## Mnemonic and Index Persistence Warning" or similar)
so anchors and links don't collide; update any internal links or references that
point to that header accordingly and verify the surrounding content mentioning
the critical mnemonic/index persistence warning still reads correctly.

In `@docs/TUI_INTERFACE.md`:
- Around line 217-222: The docs snippet for DisputeChatMessage is out of sync:
update the struct shown in TUI_INTERFACE.md to include the new field attachment:
Option<ChatAttachment>. Locate the DisputeChatMessage example in the docs and
add the attachment field with the correct type and optionality (matching the
code's DisputeChatMessage and ChatAttachment symbols) so the documentation
reflects the current struct shape.

In `@src/models.rs`:
- Around line 520-527: In get_all (pub async fn get_all(pool: &SqlitePool) ->
Result<Vec<AdminDispute>>), the SQL query passed to sqlx::query_as::<_,
AdminDispute> is missing a WHERE clause so the subsequent
.bind(DisputeStatus::InProgress.to_string()) is ignored; update the query string
to include "WHERE status = ?" before the "ORDER BY taken_at DESC" so the bind
filters rows to DisputeStatus::InProgress and then keep the existing .bind(...)
and .fetch_all(pool).await? handling.

---

Nitpick comments:
In `@Cargo.toml`:
- Line 32: Update the reqwest dependency spec in Cargo.toml from "0.13.1" to
"0.13.2" by changing the line defining reqwest = { version = "0.13.1",
default-features = false, features = ["native-tls", "json", "http2"] } so the
minimum required reqwest version matches the latest published 0.13.2.

In `@src/ui/helpers.rs`:
- Around line 271-316: In try_parse_attachment_message, when msg_type matches
"image_encrypted" or "file_encrypted" but subsequent required fields
(blossom_url, nonce, filename, or key decoding) are missing causing the function
to return None, add a log::warn that includes the msg_type and the original
content (or which key was missing) so partial/invalid attachment JSON is visible
in logs; place this warning immediately before returning None in those failure
paths after extracting msg_type (reference symbols:
try_parse_attachment_message, msg_type, blossom_url, nonce, filename,
decryption_key).

In `@src/ui/key_handler/mod.rs`:
- Around line 168-211: The handler sends attachments even when pubkey parsing
(PublicKey::parse for pk_str) or ECDH key derivation
(crate::util::blossom::derive_shared_key using admin_keys) fails; add explicit
logging in those failure branches so admins see why decryption_key remained
None. Specifically, inside the block handling
attachment.decryption_key.is_none() (the match on msg.sender and the nested
if-let Ok(sender_pk) / if-let Ok(shared) branches), add log::warn! messages when
PublicKey::parse(pk_str) returns Err and when derive_shared_key returns Err
(include dispute.dispute_id and msg.sender/context), then proceed to send via
tx.send((dispute_id_key, attachment)) as before.

In `@src/ui/tabs/disputes_in_progress_tab.rs`:
- Around line 461-468: The render_disputes_in_progress function currently
mutates app by clearing app.attachment_toast when the toast has expired, which
is a side effect that belongs in your main update/tick path; move the expiry
logic out of render_disputes_in_progress and into a centralized timer/tick
handler (e.g., the main event loop or a new handle_tick/update_timers function)
that runs before rendering, and have render_disputes_in_progress only read
app.attachment_toast; locate the code that checks app.attachment_toast and
ATTACHMENT_TOAST_DURATION and extract it into that tick/update function (or call
a small helper like expire_attachment_toast(&mut app)) so behavior is preserved
without mutating state during render.

In `@src/util/chat_utils.rs`:
- Around line 229-231: The public async function fetch_admin_chat_updates
currently accepts an unused parameter _admin_keys; either remove this parameter
from the function signature (and update all callers to stop passing it) or
explicitly mark it as ignored by renaming to _: &Keys to preserve the API while
signaling it's unused; update the function declaration for
fetch_admin_chat_updates and any call sites accordingly, and run a build to
ensure no remaining references or warnings.

Comment thread docs/ADMIN_DISPUTES.md Outdated
Comment thread README.md Outdated
Comment thread src/ui/chat.rs Outdated
Comment thread src/util/blossom.rs
Comment thread src/util/chat_utils.rs
Comment thread src/util/chat_utils.rs
…chat logging/toast expiry, and align docs with shared-key chat model

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/ADMIN_DISPUTES.md (1)

588-588: ⚠️ Potential issue | 🟡 Minor

Stale color reference in the seller chat flow diagram.

Line 588 reads TUI->>Admin: Display seller response in red, but the rest of the document (lines 63, 691–692) consistently uses Magenta for seller messages. This mermaid diagram step should say "magenta" for consistency.

📝 Suggested fix
-    TUI->>Admin: Display seller response in red
+    TUI->>Admin: Display seller response in magenta
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/ADMIN_DISPUTES.md` at line 588, Update the mermaid diagram step that
currently reads "TUI->>Admin: Display seller response in red" to say "magenta"
instead of "red" so it matches the rest of the seller-message color convention;
specifically locate the diagram arrow text "TUI->>Admin: Display seller response
in red" and change the color word to "magenta" to keep the document consistent
with the other seller message entries.
src/ui/helpers.rs (1)

625-635: ⚠️ Potential issue | 🟡 Minor

Seller header color is Color::Red but documentation specifies Magenta.

Line 628 uses Color::Red for the Seller sender_color. The documentation at docs/ADMIN_DISPUTES.md line 63 explicitly states "Color-coded messages (Cyan=Admin, Green=Buyer, Magenta=Seller)", requiring Magenta instead. Update the code to match the documented color scheme.

Proposed fix
-                ChatSender::Seller => ("Seller", Color::Red, true),
+                ChatSender::Seller => ("Seller", Color::Magenta, true),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/helpers.rs` around lines 625 - 635, The Seller message color currently
uses Color::Red in the sender tuple (see sender_label, sender_color,
is_right_aligned) for the ChatSender::Seller branch; update that branch to use
Color::Magenta instead of Color::Red so the code (sender_color for
ChatSender::Seller) matches the documentation; keep the attachment-based
content_color logic unchanged (it should still override with Color::Yellow when
msg.attachment.is_some()).
🧹 Nitpick comments (4)
src/util/chat_utils.rs (1)

56-95: Custom wrap event bypasses the NIP-59 seal layer — document the deviation.

build_custom_wrap_event skips the standard NIP-59 three-layer structure (rumor → seal → gift wrap) and instead directly encrypts a signed text_note with an ephemeral key using NIP-44. The function name makes the custom nature explicit, and the comment references the Mostro protocol chat spec, but consider adding a brief doc comment explaining why the seal layer is omitted (e.g., "Mostro protocol chat uses a simplified two-layer wrap: signed inner event encrypted with ephemeral-to-shared-key NIP-44, then wrapped as Kind::GiftWrap"). This will help future maintainers understand the intentional deviation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util/chat_utils.rs` around lines 56 - 95, Add a short doc comment to
build_custom_wrap_event explaining the intentional deviation from NIP-59: state
that instead of the standard three-layer rumor→seal→gift-wrap flow the function
creates a signed inner Event (via EventBuilder::text_note), encrypts it directly
with an ephemeral key using nip44::encrypt, and wraps it as Kind::GiftWrap;
mention why (e.g., Mostro uses a simplified two-layer approach) and reference
the relevant symbols (build_custom_wrap_event, EventBuilder::text_note,
nip44::encrypt, Kind::GiftWrap, SETTINGS) so maintainers know this is deliberate
and not a bug.
src/ui/helpers.rs (1)

264-306: Nonce field from attachment JSON is silently ignored.

try_parse_attachment_message extracts blossom_url, filename, key, and mime_type from the JSON but does not extract the nonce field. The decryption path in blossom::decrypt_blob reads the nonce from the first 12 bytes of the downloaded blob, which is correct for the stated blob layout [nonce:12][ciphertext][tag:16].

However, if the JSON nonce and the blob-embedded nonce ever diverge (e.g., a sender includes a separate nonce in JSON but does not prepend it to the blob), decryption will silently fail. Consider logging a warning if the JSON nonce is present but doesn't match the blob prefix, or at least documenting the assumption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/helpers.rs` around lines 264 - 306, The function
try_parse_attachment_message currently ignores a JSON "nonce" field which can
cause silent decryption failures; update try_parse_attachment_message to parse
the "nonce" field (base64-decode it, verify length == 12) and store it on the
ChatAttachment (add an Option<[u8;12]> or Vec<u8> field if missing) instead of
dropping it, and then ensure callers of ChatAttachment (e.g., wherever
blossom::decrypt_blob is invoked) compare the JSON nonce to the blob-prefixed
nonce and log a warning if both are present and differ; additionally
validate/handle invalid base64 or wrong-length nonces by treating them as None
and logging a debug/warn message.
src/util/blossom.rs (1)

148-175: A new reqwest::Client is created per save — consider reusing.

save_attachment_to_disk constructs a fresh reqwest::Client on line 157 for each invocation. Client::new() allocates a connection pool and TLS state. Since saves are infrequent this is not a blocker, but if the caller already holds a client (e.g., in spawn_save_attachment), passing it in would avoid repeated setup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util/blossom.rs` around lines 148 - 175, The function
save_attachment_to_disk creates a new reqwest::Client per call which is
expensive; change its signature to accept a shared Client (e.g., client:
&reqwest::Client or client: Arc<Client>) instead of constructing one inside,
update call sites such as spawn_save_attachment to pass the existing client
through, and remove Client::new() from save_attachment_to_disk so
fetch_blob(&client, ...) uses the provided client; ensure lifetimes/ownership
are adjusted (or wrap in Arc) and update imports/signatures accordingly.
src/ui/key_handler/mod.rs (1)

167-233: Two shared-key derivation paths are intentionally used for different cryptographic purposes—document the distinction to prevent accidental swapping.

blossom::derive_shared_key (line 197) derives keys via nostr_sdk::secp256k1::ecdh::shared_secret_point for ChaCha20-Poly1305 file decryption, while chat_utils::derive_shared_keys uses nostr_sdk::util::generate_shared_key for NIP-59 message wrapping. Both approaches are documented at their respective module levels (blossom.rs module doc and chat_utils.rs function doc), but there is no cross-reference explaining why both coexist. Adding a comment in key_handler (near line 197) clarifying that blossom::derive_shared_key is specifically for file decryption would prevent future maintainers from accidentally substituting chat_utils::derive_shared_keys or consolidating the two paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ui/key_handler/mod.rs` around lines 167 - 233, Add a short clarifying
comment immediately above the blossom::derive_shared_key usage in
key_handler::handle_key_event (where the attachment decryption_key is derived)
stating that blossom::derive_shared_key derives a ChaCha20-Poly1305
file-decryption key via nostr_sdk::secp256k1::ecdh::shared_secret_point and must
not be swapped with chat_utils::derive_shared_keys, which uses
nostr_sdk::util::generate_shared_key for NIP-59 message wrapping; mention both
functions by name and their distinct cryptographic purpose to prevent future
accidental consolidation or substitution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@docs/ADMIN_DISPUTES.md`:
- Line 588: Update the mermaid diagram step that currently reads "TUI->>Admin:
Display seller response in red" to say "magenta" instead of "red" so it matches
the rest of the seller-message color convention; specifically locate the diagram
arrow text "TUI->>Admin: Display seller response in red" and change the color
word to "magenta" to keep the document consistent with the other seller message
entries.

In `@src/ui/helpers.rs`:
- Around line 625-635: The Seller message color currently uses Color::Red in the
sender tuple (see sender_label, sender_color, is_right_aligned) for the
ChatSender::Seller branch; update that branch to use Color::Magenta instead of
Color::Red so the code (sender_color for ChatSender::Seller) matches the
documentation; keep the attachment-based content_color logic unchanged (it
should still override with Color::Yellow when msg.attachment.is_some()).

---

Nitpick comments:
In `@src/ui/helpers.rs`:
- Around line 264-306: The function try_parse_attachment_message currently
ignores a JSON "nonce" field which can cause silent decryption failures; update
try_parse_attachment_message to parse the "nonce" field (base64-decode it,
verify length == 12) and store it on the ChatAttachment (add an Option<[u8;12]>
or Vec<u8> field if missing) instead of dropping it, and then ensure callers of
ChatAttachment (e.g., wherever blossom::decrypt_blob is invoked) compare the
JSON nonce to the blob-prefixed nonce and log a warning if both are present and
differ; additionally validate/handle invalid base64 or wrong-length nonces by
treating them as None and logging a debug/warn message.

In `@src/ui/key_handler/mod.rs`:
- Around line 167-233: Add a short clarifying comment immediately above the
blossom::derive_shared_key usage in key_handler::handle_key_event (where the
attachment decryption_key is derived) stating that blossom::derive_shared_key
derives a ChaCha20-Poly1305 file-decryption key via
nostr_sdk::secp256k1::ecdh::shared_secret_point and must not be swapped with
chat_utils::derive_shared_keys, which uses nostr_sdk::util::generate_shared_key
for NIP-59 message wrapping; mention both functions by name and their distinct
cryptographic purpose to prevent future accidental consolidation or
substitution.

In `@src/util/blossom.rs`:
- Around line 148-175: The function save_attachment_to_disk creates a new
reqwest::Client per call which is expensive; change its signature to accept a
shared Client (e.g., client: &reqwest::Client or client: Arc<Client>) instead of
constructing one inside, update call sites such as spawn_save_attachment to pass
the existing client through, and remove Client::new() from
save_attachment_to_disk so fetch_blob(&client, ...) uses the provided client;
ensure lifetimes/ownership are adjusted (or wrap in Arc) and update
imports/signatures accordingly.

In `@src/util/chat_utils.rs`:
- Around line 56-95: Add a short doc comment to build_custom_wrap_event
explaining the intentional deviation from NIP-59: state that instead of the
standard three-layer rumor→seal→gift-wrap flow the function creates a signed
inner Event (via EventBuilder::text_note), encrypts it directly with an
ephemeral key using nip44::encrypt, and wraps it as Kind::GiftWrap; mention why
(e.g., Mostro uses a simplified two-layer approach) and reference the relevant
symbols (build_custom_wrap_event, EventBuilder::text_note, nip44::encrypt,
Kind::GiftWrap, SETTINGS) so maintainers know this is deliberate and not a bug.

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LFGTM!!!

@grunch grunch merged commit 6fa6cde into main Feb 19, 2026
11 checks passed
@grunch grunch deleted the file-attachment branch February 19, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants