feat(chat): mark outgoing messages that fail to send

Outgoing echoes were recorded optimistically regardless of whether the broadcast
was actually submitted. Now broadcastChatMemos / broadcastChatMemosLite return
whether the send was submitted (false on immediate failures: not connected, no
spendable z-address, or a lite send already in progress), and the echo records a
ChatDelivery status (Sent / Failed). The Chat tab shows a "not sent" marker in
the error color on failed messages. The status persists in the DB (backward-
compatible: old rows deserialize as Sent).

(A later async failure — an opid that fails after submission — still surfaces via
the existing send-progress notification; only the submit gate is reflected here.)

Also removes the now-unused chat_readonly_note string (the composer replaced the
read-only footer). Test: delivery status round-trips through the DB. Verified:
Linux + Windows build with chat ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:11:50 -05:00
parent 6c08a08127
commit 6bfb56aeb9
7 changed files with 41 additions and 16 deletions

View File

@@ -2534,23 +2534,22 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
void App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
{
if (memos.recipientZaddr.empty()) return;
if (memos.recipientZaddr.empty()) return false;
if (lite_wallet_) {
broadcastChatMemosLite(memos);
return;
return broadcastChatMemosLite(memos);
}
if (!state_.connected || !rpc_ || !worker_) {
ui::Notifications::instance().error("Not connected — chat message not sent.");
return;
return false;
}
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
if (from.empty()) {
ui::Notifications::instance().error("No spendable z-address to send chat from.");
return;
return false;
}
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
@@ -2569,15 +2568,16 @@ void App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{});
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller)
}
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
// Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value
// outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite
// broadcast log; the message is already echoed locally.
void App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
{
if (!lite_wallet_) return;
if (!lite_wallet_) return false;
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false);
wallet::LiteSendRequest req;
for (const auto& out : outputs) {
@@ -2589,7 +2589,9 @@ void App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
}
if (!lite_wallet_->sendTransaction(req)) {
ui::Notifications::instance().error("A send is already in progress, or no wallet is open.");
return false;
}
return true;
}
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
@@ -2620,7 +2622,7 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
ui::Notifications::instance().error("Could not compose the message (too long?).");
return;
}
broadcastChatMemos(memos);
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing;
@@ -2632,6 +2634,7 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
chat_service_.recordOutgoing(echo);
}
@@ -2652,7 +2655,7 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
ui::Notifications::instance().error("Could not compose the contact request (invalid address / text?).");
return;
}
broadcastChatMemos(memos);
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing;
@@ -2663,8 +2666,9 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
chat_service_.recordOutgoing(echo);
ui::Notifications::instance().success("Contact request queued.");
if (submitted) ui::Notifications::instance().success("Contact request queued.");
}
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's