diff --git a/src/app.h b/src/app.h index e7cf1b6..88fafe2 100644 --- a/src/app.h +++ b/src/app.h @@ -1095,6 +1095,10 @@ private: }; LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel std::deque chat_send_queue_; // chat/contact sends awaiting a free channel + verified note + // Larger-messages tier 3: a message split across several transactions shares one echo. This counts the + // not-yet-broadcast parts per echo id; the echo flips to Sent only when the last part lands, and to + // Failed (with the remaining parts purged) if any part fails. Absent/one ⇒ an ordinary single-tx send. + std::unordered_map chat_send_parts_remaining_; // Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat // submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on // a transient-funds failure so we stop draining until the next refresh/scan restores the truth. diff --git a/src/app_network.cpp b/src/app_network.cpp index 8fe4504..3ef2545 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -4255,6 +4255,7 @@ void App::resetChatSession() auto orphanedSend = std::move(lite_send_callback_); lite_send_callback_ = nullptr; chat_send_queue_.clear(); + chat_send_parts_remaining_.clear(); // no queued parts survive a reset — drop their outstanding counts inflight_op_ = LiteInflightOp{}; chat_verified_note_budget_ = 0; chat_pipeline_note_count_ = 0; @@ -4659,10 +4660,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string& return; } - chat::OutgoingChatMemos memos; + // Compose into one OutgoingChatMemos per transaction: short messages yield one (tier 1/2), a long one + // several (tier 3), each a self-contained chat send carrying a slice of the one ciphertext. + std::vector parts; const auto composeStatus = - chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos); - if (composeStatus != chat::ChatComposeStatus::Ok) { + chat_service_.composeMessageParts(myReply, peerPubKey, peerZaddr, conversationId, text, parts); + if (composeStatus != chat::ChatComposeStatus::Ok || parts.empty()) { ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong ? "chat_toast_too_long" : "chat_toast_compose_failed")); return; @@ -4679,9 +4682,14 @@ void App::sendChatMessage(const std::string& conversationId, const std::string& echo.payload_position = 0; echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status - // Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes + // A tier-3 message rides several transactions under one echo: track the remaining parts so the echo + // flips to Sent only when the LAST part lands (see onChatBroadcastResult). A single-part send is left + // out of the map and resolves immediately as before. + if (parts.size() > 1) chat_send_parts_remaining_[echo.txid] = static_cast(parts.size()); + // Hand each part to the note-buffer coordinator, which drains the queue against verified notes // (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result. - enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid); + for (auto& p : parts) + enqueueChatSend(LiteOpKind::ChatSend, p, echo.txid); } void App::sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label) @@ -5233,8 +5241,19 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st const bool frontMatches = !chat_send_queue_.empty() && chat_send_queue_.front().echoLocalId == op.echoLocalId; if (ok) { - chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent); if (frontMatches) chat_send_queue_.pop_front(); + // Tier-3: a message split across several transactions keeps the echo at "Sending" until its LAST + // part has been broadcast. Decrement the outstanding-part count and only resolve on the last one. + auto it = chat_send_parts_remaining_.find(op.echoLocalId); + if (it != chat_send_parts_remaining_.end()) { + if (--it->second > 0) { + wallet::liteLog("chat: tier-3 part broadcast; " + std::to_string(it->second) + + " part(s) of this message still to send"); + return; // not fully sent yet — echo stays "Sending" + } + chat_send_parts_remaining_.erase(it); + } + chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent); wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) + " still queued"); return; @@ -5242,16 +5261,26 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st if (frontMatches && isTransientVerifiedFundsError(error) && chat_send_queue_.front().retries < kMaxChatSendRetries) { // The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh - // restores the true verified-note count; keep the message queued ("Sending") and retry then. + // restores the true verified-note count; keep the message queued ("Sending") and retry then. A + // tier-3 part defers in place like any other — its outstanding-part count is untouched. chat_verified_note_budget_ = 0; ++chat_send_queue_.front().retries; wallet::liteLog("chat: send deferred — funds still confirming (retry " + std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'"); return; // leave at front } + // Hard failure. For a tier-3 message, drop its remaining queued parts: a message missing any slice can + // never reassemble, so broadcasting the rest only wastes fees. Fail the whole message once. + auto it = chat_send_parts_remaining_.find(op.echoLocalId); + if (it != chat_send_parts_remaining_.end()) { + chat_send_parts_remaining_.erase(it); + chat_send_queue_.erase(std::remove_if(chat_send_queue_.begin(), chat_send_queue_.end(), + [&](const QueuedChatOp& q) { return q.echoLocalId == op.echoLocalId; }), chat_send_queue_.end()); + } else if (frontMatches) { + chat_send_queue_.pop_front(); + } chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed, util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason - if (frontMatches) chat_send_queue_.pop_front(); wallet::liteLog("chat: send FAILED: " + error); } diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 83c6170..60d551c 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -304,6 +304,17 @@ ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr, peerPublicKeyHex, peerZaddr, conversationId, plaintext, out); } +ChatComposeStatus ChatService::composeMessageParts(const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + std::vector& out) const { + if (!has_identity_) return ChatComposeStatus::MissingField; + return splitOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr, + peerPublicKeyHex, peerZaddr, conversationId, plaintext, out); +} + ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr, const std::string& peerZaddr, const std::string& conversationId, diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index ba0090e..eac78ae 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -110,6 +110,15 @@ public: const std::string& conversationId, const std::string& plaintext, OutgoingChatMemos& out) const; + // Construct the outgoing memos for an ENCRYPTED message, splitting it across as many transactions as + // its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in + // order; a short message yields a single element (identical to composeMessage). + ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + std::vector& out) const; // Construct the outgoing memos for a plaintext contact request (no peer key needed yet). ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr, const std::string& peerZaddr,