feat(chat): broadcast a tier-3 message across its transactions under one echo
Send side of tier-3 larger messages. sendChatMessage now composes via composeMessageParts (splitOutgoingMessage), which returns one OutgoingChatMemos per transaction, and enqueues each on the existing note-buffer coordinator — so the parts drain sequentially against verified notes exactly like ordinary chat sends, with no fund-path change (each part is the same pinned 0-value chatSendOutputs z_sendmany). A single echo bubble represents the whole message. A new per-echo outstanding- part counter holds it at "Sending" until the LAST part is broadcast, then flips it to Sent; if any part hits a hard failure the remaining queued parts are purged (a message missing a slice can never reassemble, so sending the rest only wastes fees) and the whole message is failed once — its existing Retry recomposes and re-splits from the body. A transient-funds deferral still retries the part in place. The counter map is cleared alongside the send queue on a wallet reset. Inert until the composer cap is raised (the current 4000-char cap yields a single tier-2 transaction); that + the multi-transaction composer indicator land next. Build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1095,6 +1095,10 @@ private:
|
||||
};
|
||||
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
||||
std::deque<QueuedChatOp> 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<std::string, int> 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.
|
||||
|
||||
+37
-8
@@ -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<chat::OutgoingChatMemos> 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<int>(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OutgoingChatMemos>& 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,
|
||||
|
||||
@@ -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<OutgoingChatMemos>& 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,
|
||||
|
||||
Reference in New Issue
Block a user