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

@@ -602,8 +602,8 @@ private:
void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
void broadcastChatMemos(const chat::OutgoingChatMemos& memos); // full-node z_sendmany / lite
void broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;

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

View File

@@ -250,6 +250,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
json["b"] = message.body;
json["ts"] = message.timestamp;
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
json["dl"] = static_cast<int>(message.delivery);
return json.dump();
}
@@ -266,6 +267,7 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
out.body = parsed.value("b", std::string());
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
return true;
} catch (const std::exception&) {
return false;

View File

@@ -10,6 +10,9 @@ namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest };
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
// spendable address, a send already in progress). Always Sent for incoming.
enum class ChatDelivery { Sent, Failed };
struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming;
@@ -21,6 +24,7 @@ struct ChatMessage {
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
std::size_t payload_position = 0; // together with txid, the dedup key
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only
};
} // namespace dragonx::chat

View File

@@ -226,14 +226,16 @@ void RenderChatTab(App* app)
for (const auto& m : messages) {
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
// Meta line: who + time (+ request tag).
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
// Meta line: who + time (+ request tag / failed marker).
std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName;
const std::string when = formatTime(m.timestamp);
if (!when.empty()) who += " " + when;
if (request) who += " [" + std::string(TR("chat_contact_request")) + "]";
if (failed) who += " · " + std::string(TR("chat_send_failed"));
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text,
outgoing ? material::Primary() : material::OnSurfaceMedium());
failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium()));
ImGui::TextUnformatted(who.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();

View File

@@ -216,7 +216,7 @@ void I18n::loadBuiltinEnglish()
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
strings_["chat_you"] = "You";
strings_["chat_contact_request"] = "contact request";
strings_["chat_readonly_note"] = "Read-only — sending messages arrives in a later update.";
strings_["chat_send_failed"] = "not sent";
strings_["chat_new_button"] = "New conversation";
strings_["chat_select_hint"] = "Select a conversation to view it.";
strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do.";

View File

@@ -5846,6 +5846,19 @@ void testHushChatDatabase()
}
}
// Delivery status persists (a failed outgoing echo round-trips as Failed).
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret("delivery-seed"));
ChatMessage m = makeMsg("tD", 1, "cD", "not-sent one", ChatMessageKind::Message, 555);
m.direction = ChatDirection::Outgoing;
m.delivery = ChatDelivery::Failed;
EXPECT_TRUE(db.append(m));
std::vector<ChatMessage> loaded = db.load();
EXPECT_EQ((int)loaded.size(), 1);
EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed);
}
scrub(dbPath); scrub(dbPath2);
}