diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index d6f9d1f..a6d186f 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index 7b2a72c..ac91ca4 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -162,9 +162,11 @@ "chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.", "chat_send": "Senden", "chat_send_failed": "nicht gesendet", + "chat_sending": "senden…", "chat_time_now": "jetzt", "chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).", "chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.", + "chat_toast_need_funds": "Ein kleines geschütztes Guthaben ist nötig, um Chats zu senden (zur Deckung der Gebühr).", "chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.", "chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.", "chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).", diff --git a/res/lang/es.json b/res/lang/es.json index 7e0db13..bd11176 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -162,9 +162,11 @@ "chat_select_hint": "Selecciona una conversación para verla.", "chat_send": "Enviar", "chat_send_failed": "no enviado", + "chat_sending": "enviando…", "chat_time_now": "ahora", "chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).", "chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.", + "chat_toast_need_funds": "Necesitas un pequeño saldo blindado para enviar chats (para cubrir la comisión).", "chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.", "chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.", "chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).", diff --git a/res/lang/fr.json b/res/lang/fr.json index bba1595..aa54a97 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -162,9 +162,11 @@ "chat_select_hint": "Sélectionnez une conversation pour l'afficher.", "chat_send": "Envoyer", "chat_send_failed": "non envoyé", + "chat_sending": "envoi…", "chat_time_now": "à l'instant", "chat_toast_compose_failed": "Impossible de composer le message (trop long ?).", "chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.", + "chat_toast_need_funds": "Un petit solde blindé est nécessaire pour envoyer des messages (pour couvrir les frais).", "chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.", "chat_toast_not_connected": "Non connecté — message non envoyé.", "chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).", diff --git a/res/lang/ja.json b/res/lang/ja.json index 8bfef89..ba02496 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -162,9 +162,11 @@ "chat_select_hint": "表示する会話を選択してください。", "chat_send": "送信", "chat_send_failed": "未送信", + "chat_sending": "送信中…", "chat_time_now": "たった今", "chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。", "chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。", + "chat_toast_need_funds": "チャットを送信するには、手数料を賄うための少額のシールド残高が必要です。", "chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。", "chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。", "chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。", diff --git a/res/lang/ko.json b/res/lang/ko.json index 6089ba6..a62e0cc 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -162,9 +162,11 @@ "chat_select_hint": "볼 대화를 선택하세요.", "chat_send": "전송", "chat_send_failed": "전송 안 됨", + "chat_sending": "전송 중…", "chat_time_now": "방금", "chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).", "chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.", + "chat_toast_need_funds": "채팅을 보내려면 수수료를 낼 소액의 보호 잔액이 필요합니다.", "chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.", "chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.", "chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).", diff --git a/res/lang/pt.json b/res/lang/pt.json index cbe03ed..84a8bb3 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -162,9 +162,11 @@ "chat_select_hint": "Selecione uma conversa para visualizá-la.", "chat_send": "Enviar", "chat_send_failed": "não enviada", + "chat_sending": "enviando…", "chat_time_now": "agora", "chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).", "chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.", + "chat_toast_need_funds": "É necessário um pequeno saldo blindado para enviar chats (para cobrir a taxa).", "chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.", "chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.", "chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).", diff --git a/res/lang/ru.json b/res/lang/ru.json index 1b8b340..5ef3838 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -162,9 +162,11 @@ "chat_select_hint": "Выберите переписку для просмотра.", "chat_send": "Отправить", "chat_send_failed": "не отправлено", + "chat_sending": "отправка…", "chat_time_now": "сейчас", "chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).", "chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.", + "chat_toast_need_funds": "Для отправки сообщений нужен небольшой экранированный баланс (для оплаты комиссии).", "chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.", "chat_toast_not_connected": "Нет подключения — сообщение не отправлено.", "chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).", diff --git a/res/lang/zh.json b/res/lang/zh.json index 3bfd2a0..71c2d4f 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -162,9 +162,11 @@ "chat_select_hint": "选择一个对话以查看。", "chat_send": "发送", "chat_send_failed": "未发送", + "chat_sending": "发送中…", "chat_time_now": "刚刚", "chat_toast_compose_failed": "无法编写该消息(内容过长?)。", "chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。", + "chat_toast_need_funds": "发送聊天需要少量屏蔽余额(用于支付手续费)。", "chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。", "chat_toast_not_connected": "未连接——聊天消息未发送。", "chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。", diff --git a/src/app.h b/src/app.h index 89ce2b1..3313425 100644 --- a/src/app.h +++ b/src/app.h @@ -717,9 +717,14 @@ private: void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void provisionChatIdentityFromSecret(std::string secret); - std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat + std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to) + // A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity + // reply address. The reply-to in the memo stays the identity address, so paying from a different + // funded note is transport-transparent. Empty if no z-address can cover the fee. + std::string chatPayFromZaddr(double fee) const; std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid - bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted + // Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed. + bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // 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. diff --git a/src/app_network.cpp b/src/app_network.cpp index 85ac2c7..2ec5144 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2885,6 +2885,27 @@ std::string App::chatReplyZaddr() return best; } +std::string App::chatPayFromZaddr(double fee) const +{ + // z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity + // reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance + // spendable z-address that can cover the fee. The memo still advertises the identity address as + // reply-to, so paying from a different note doesn't change who the peer replies to. + std::string reply; + if (settings_) reply = settings_->getChatReplyZaddr(); + for (const auto& a : state_.z_addresses) + if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply; + + std::string best; + double bestBal = -1.0; + for (const auto& a : state_.z_addresses) + if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) { + best = a.address; + bestBal = a.balance; + } + return best; // empty → no z-address can cover the fee +} + // A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for // new conversation ids. Random — collisions are astronomically unlikely. std::string App::generateChatLocalId(const char* prefix, int numBytes) const @@ -2904,21 +2925,35 @@ 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. -bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) +// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a +// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the +// global default-fee setting drop chat below a working minimum (well above the node's min relay fee). +static constexpr double kChatMinFeeDrgx = 0.0001; + +bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { if (memos.recipientZaddr.empty()) return false; if (lite_wallet_) { - return broadcastChatMemosLite(memos); + const bool ok = broadcastChatMemosLite(memos); + // The lite backend has no per-send completion callback here; resolve optimistically on a + // successful queue (its own broadcast log surfaces a later failure). + if (ok) chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent); + return ok; } if (!state_.connected || !rpc_ || !worker_) { ui::Notifications::instance().error(TR("chat_toast_not_connected")); return false; } - const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address + + const double fee = std::max(settings_ ? settings_->getDefaultFee() : kChatMinFeeDrgx, kChatMinFeeDrgx); + + // Pay from a z-address that can actually cover the fee. The memo still advertises the identity + // reply address, so the peer replies to the right place regardless of which note paid. + const std::string from = chatPayFromZaddr(fee); if (from.empty()) { - ui::Notifications::instance().error(TR("chat_toast_no_zaddr")); + ui::Notifications::instance().error(TR("chat_toast_need_funds")); return false; } @@ -2933,12 +2968,16 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) recipients.push_back(std::move(recipient)); } - const double fee = settings_ ? settings_->getDefaultFee() : 0.0001; // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output. + // The callback flips the echo to Sent/Failed once the async op resolves — real delivery status. 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) + "HushChat / broadcast", /*markFeeGapRetry*/ true, + [this, echoLocalId](bool ok, const std::string& /*result*/) { + chat_service_.resolveOutgoing(echoLocalId, + ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed); + }); + return true; // submitted (async build/broadcast; the callback resolves the final status) } // Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does @@ -2997,8 +3036,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string& ui::Notifications::instance().error(TR("chat_toast_compose_failed")); return; } - const bool submitted = broadcastChatMemos(memos); - chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::Message; @@ -3009,8 +3046,12 @@ 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); + echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it + chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status + // A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry + // affordance appears; otherwise the async callback flips it to Sent/Failed. + if (!broadcastChatMemos(memos, echo.txid)) + chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed); } void App::startChatConversation(const std::string& peerZaddr, const std::string& text) @@ -3030,8 +3071,6 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string& ui::Notifications::instance().error(TR("chat_toast_request_compose_failed")); return; } - const bool submitted = broadcastChatMemos(memos); - chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::ContactRequest; @@ -3041,9 +3080,12 @@ 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); - if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued")); + echo.delivery = chat::ChatDelivery::Sending; + chat_service_.recordOutgoingPending(echo); + if (broadcastChatMemos(memos, echo.txid)) + ui::Notifications::instance().success(TR("chat_toast_request_queued")); + else + chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed); } // 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 29f767c..243247c 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -267,7 +267,10 @@ 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 + out.delivery = static_cast(parsed.value("dl", 0)); // old rows → Sent (0) + // A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it + // optimistically to Sent on load so it can't show a stuck spinner forever. + if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent; return true; } catch (const std::exception&) { return false; diff --git a/src/chat/chat_message.h b/src/chat/chat_message.h index d55e1e0..811dce7 100644 --- a/src/chat/chat_message.h +++ b/src/chat/chat_message.h @@ -10,9 +10,11 @@ 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 }; +// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the +// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected). +// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST +// stay 0 and new states are APPENDED — never reordered. +enum class ChatDelivery { Sent, Failed, Sending }; struct ChatMessage { ChatDirection direction = ChatDirection::Incoming; diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index c994cd6..8e3bf4c 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -109,4 +109,15 @@ bool ChatService::recordOutgoing(const ChatMessage& message) { return false; } +bool ChatService::recordOutgoingPending(const ChatMessage& message) { + // In-memory only — deliberately NOT persisted yet, so "Sending" never survives to a restart; the + // row is written by resolveOutgoing() once the broadcast resolves to Sent/Failed. + return store_.append(message); +} + +void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) { + const ChatMessage* updated = store_.updateDelivery(txid, delivery); + if (updated && db_) db_->append(*updated); // first persist, carrying the final status +} + } // namespace dragonx::chat diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index 2366606..b51a438 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -76,6 +76,12 @@ public: // only local record of what we sent.) bool recordOutgoing(const ChatMessage& message); + // Two-phase echo for delivery tracking: record in-memory only (shows immediately as Sending), + // then resolveOutgoing() flips the status once the broadcast completes AND persists it with the + // final status — so a restart never shows a stuck "Sending". + bool recordOutgoingPending(const ChatMessage& message); + void resolveOutgoing(const std::string& txid, ChatDelivery delivery); + const ChatStore& store() const { return store_; } ChatStore& store() { return store_; } diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 3dbb0bd..82cf76b 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -34,6 +34,16 @@ std::vector ChatStore::conversation(const std::string& conversation return out; } +const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) { + for (auto& message : messages_) { + if (message.txid == txid) { + message.delivery = delivery; + return &message; + } + } + return nullptr; +} + std::vector ChatStore::conversationIds() const { std::vector ids; std::unordered_set seenIds; diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 4795fc7..60559a0 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -21,6 +21,10 @@ public: // Messages in a conversation, in insertion order. std::vector conversation(const std::string& conversationId) const; + // Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated + // message (for the caller to persist), or nullptr if no message has that txid. + const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery); + // Distinct conversation ids, in first-seen order. std::vector conversationIds() const; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 36a4f2c..978c723 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -460,6 +460,7 @@ void RenderChatTab(App* app) const bool outgoing = (m.direction == chat::ChatDirection::Outgoing); const bool request = (m.kind == chat::ChatMessageKind::ContactRequest); const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed; + const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending; const std::int64_t minute = m.timestamp / 60; const bool startGroup = (prevDir != static_cast(outgoing)) || (minute != prevMin); @@ -514,6 +515,15 @@ void RenderChatTab(App* app) ImGui::SameLine(); if (ImGui::SmallButton(TR("chat_retry"))) { app->sendChatMessage(sel->cid, m.body); s_scroll_to_cid = sel->cid; } } + // In-flight send → subtle right-aligned "sending…"; the op callback resolves it. + if (sending) { + ImGui::Dummy(ImVec2(0.0f, 1.0f * dp)); + const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw)); + ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(TR("chat_sending")); + ImGui::PopStyleColor(); ImGui::PopFont(); + } ImGui::Dummy(ImVec2(0.0f, 5.0f * dp)); ImGui::PopID(); prevMin = minute; prevDir = static_cast(outgoing); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 8d9f768..f951f58 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -236,6 +236,8 @@ void I18n::loadBuiltinEnglish() strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?)."; strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?)."; strings_["chat_toast_request_queued"] = "Contact request queued."; + strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee)."; + strings_["chat_sending"] = "sending\xE2\x80\xA6"; strings_["chat_time_now"] = "now"; strings_["chat_retry"] = "Retry"; strings_["chat_jump_latest"] = "Latest";