diff --git a/src/app.h b/src/app.h index 8bcafc7..5063eb6 100644 --- a/src/app.h +++ b/src/app.h @@ -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; diff --git a/src/app_network.cpp b/src/app_network.cpp index e866b64..d5da0fb 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp index c5be405..29f767c 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -250,6 +250,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const json["b"] = message.body; json["ts"] = message.timestamp; json["pos"] = static_cast(message.payload_position); + json["dl"] = static_cast(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(0)); out.payload_position = static_cast(parsed.value("pos", static_cast(0))); + out.delivery = static_cast(parsed.value("dl", 0)); // old rows → Sent return true; } catch (const std::exception&) { return false; diff --git a/src/chat/chat_message.h b/src/chat/chat_message.h index 1dc7365..d55e1e0 100644 --- a/src/chat/chat_message.h +++ b/src/chat/chat_message.h @@ -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 diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 0aa6594..250efeb 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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(); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 02267fb..078bdfa 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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."; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 188c756..d431811 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 loaded = db.load(); + EXPECT_EQ((int)loaded.size(), 1); + EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed); + } + scrub(dbPath); scrub(dbPath2); }